Continuous scroll position for Titanium's Android ListView and TableView.
On Android, Titanium's ListView tells you where it is scrolled to at exactly
two moments: when a gesture starts (scrolling) and when it comes to rest
(scrollend). Nothing is reported while the list is actually moving. Fling a
long list and your JavaScript hears about the starting position, then silence
for as long as the fling lasts, then the final position.
iOS has no such gap — scrolling fires continuously there — so this is one of
those bugs that only shows up when someone finally tests the Android build.
That gap is fine if you only need to know where the user landed. It is not fine if you are drawing anything that has to track the list as it moves:
- sticky section headers — a pinned day/category bar above the list
- chrome that reacts to scrolling — collapsing toolbars, shadows, scroll-to-top buttons, progress indicators
- loading more rows before the user reaches the bottom
The usual workarounds all fail in their own way. setMarker/addMarker fires
when a row enters the bottom of the viewport, which tells you nothing
reliable about what is at the top — on a tall screen showing several
sections at once, a marker-driven header changes to the wrong section while the
top of the list has not moved. Guessing from item heights breaks the moment a
row wraps to two lines. Polling on a timer is jittery and burns battery.
This module removes the guesswork. It attaches a real
RecyclerView.OnScrollListener — the same callback the platform uses
internally — to the RecyclerView backing the Titanium view, and forwards the
visible range to JavaScript whenever it changes. The position is the
platform's own answer, not an estimate.
Download ti.scrollwatch-android-1.0.0.zip from
Releases and
unzip it into your project root (the zip contains a modules/ directory, so it
lands in the right place):
unzip ti.scrollwatch-android-1.0.0.zip -d /path/to/your/appThen add it to tiapp.xml:
<modules>
<module platform="android">ti.scrollwatch</module>
</modules>Requires Titanium SDK 13.0.0.GA or later.
const scrollwatch = require('ti.scrollwatch');
scrollwatch.watch($.myListView, function (e) {
// e.firstVisiblePosition first row visible, even partially
// e.lastVisiblePosition last row visible, even partially
// e.firstCompletelyVisiblePosition first fully visible row
// e.dx, e.dy pixels scrolled since the last event
Ti.API.info('top row is ' + e.firstVisiblePosition);
});The reported positions are flat row indexes across the whole list, not
{section, item} pairs. Two things to know when mapping them back to sections:
- Titanium's Android
ListViewdoes not give section headers a row of their own. A flat position is a running count of items only. - So section n starts at the sum of the item counts of the sections before it.
// Build this once, when you build the sections.
const sectionStarts = [];
let flat = 0;
sections.forEach(function (section, i) {
sectionStarts[i] = flat;
flat += section.items.length; // no +1 — headers take no row
});
function sectionForPosition(position) {
let i = sectionStarts.length - 1;
while (i > 0 && sectionStarts[i] > position) { i--; }
return i;
}If you are unsure whether your list matches this model, verify it against the
ListView's own firstVisibleSectionIndex in a scrollend handler — it is
authoritative, just too late to drive an animation.
The RecyclerView does not exist until the window has been laid out, so
watch() returns false if you call it too early. Retry briefly:
function attach(attempt) {
if (scrollwatch.watch($.myListView, onScroll)) { return; }
if (attempt < 10) {
setTimeout(function () { attach(attempt + 1); }, 200);
}
}
attach(0);scrollwatch.isReady($.myListView) answers the same question without
attaching.
scrollwatch.unwatch($.myListView);Not strictly required — the listener dies with the RecyclerView — but it is good manners if you keep the view around and no longer care.
| Method | Returns | Notes |
|---|---|---|
watch(view, callback) |
Boolean |
false if the view is not laid out yet. Watching an already-watched view replaces the callback. |
unwatch(view) |
Boolean |
false if the view was not being watched. |
isReady(view) |
Boolean |
Whether watch() would succeed right now. |
Callback event properties: firstVisiblePosition, lastVisiblePosition,
firstCompletelyVisiblePosition, dx, dy.
The callback fires only when the visible range changes, not on every frame of a fling, so it does not flood the JS bridge.
./build.sh # -> dist/ti.scrollwatch-android-1.0.0.zipNeeds the Titanium CLI, an Android SDK, and NDK r25 or r26 — Titanium's
ndk-build toolchain fails on r27+, where lld dropped a flag it still passes.
Two quirks are worth knowing if you build other Titanium modules:
- The Titanium CLI refuses to build a module that lives inside an app project,
which is why
build.shcopies the source to a temp directory first. - On some Linux distributions the SDK's module
Android.mktemplate passes a bare-L$(SYSROOT)/usr/lib.SYSROOTis empty under modernndk-build, so the host's/usr/libends up on the link line and lld chokes on the system's linker scripts — reported as the thoroughly misleading--fix-cortex-a53-843419 is only supported on AArch64 targets. Removing that path from the template fixes it.
The shipped binaries are linked with -Wl,-z,max-page-size=16384, so they are
16 KB page-size compatible for Android 15+ and GrapheneOS.
GPL-3.0-or-later. Copyright (c) 2026 Centreville Tech LLC. See LICENSE.