Scrolling to Posts in Apps
The liveblog jumps to a post when a reader follows a deep link or taps a highlight, chapter or scorer, and when your code calls navigateToEventId. In a browser, the embed scrolls the page itself and keeps the post in place while content above it is still loading. In an app, whether it can do that depends on how the web view is embedded.
When You Need the Scroll Hook
You need the hook when the liveblog’s web view sits inside a native scroll view or list and is sized to its content, for example in a UICollectionView cell on iOS or a RecyclerView row on Android. Such a web view never scrolls itself, so the embed cannot bring the post into view. Instead, it calls window.tickarooLiveblogScrollTo, and your app scrolls its native view.
You do not need the hook for a full-screen web view that scrolls its own content. There, the embed scrolls the page as it does in a browser.
The tickarooLiveblogScrollTo Hook
window.tickarooLiveblogScrollTo = function (top, info) {
// scroll your native view so that the post sits at the top of the visible area
};
Arguments
topis the post’s position in CSS pixels, in page coordinates (the page’s scroll position plus the post’s top in the viewport), minus the jump’s top offset. A web view sized to its content does not scroll, so its scroll position stays 0 andtopis measured from the top edge of the web view. To get the native target, add the web view’s position in your scroll view’s content.- One CSS pixel is one point on iOS and one dp on Android, as long as the page sets
<meta name="viewport" content="width=device-width, initial-scale=1">. - The top offset is the
topOffsetpassed tonavigateToEventId. Without it, the embed uses the height of a fixed or sticky header it detects inside the web page, or 0. Your native bars and insets are not included, because the embed cannot see them.
- One CSS pixel is one point on iOS and one dp on Android, as long as the page sets
infois an object{ correction, jumpId }.correction(boolean) isfalsefor the first call of a jump andtruefor every later call of the same jump.jumpId(number) is the same for all calls of one jump and different for every jump on the page.
Declare info as a Second Parameter
When a jump starts, the embed checks how many parameters your function declares:
- Two parameters,
function (top, info): every call passestopandinfo, and corrections can arrive for up to 10 s after the jump. - One parameter,
function (top): every call passestopalone, exactly one argument. Corrections stop 1 s after the last call, because your app cannot tell a correction from a new jump. The jump call is skipped when the post is already within 5 px of the top offset.
Declare info as a plain second parameter. If you give it a default value (info = {}) or use a rest parameter, the function counts as a one-parameter hook. If your hook is called without info, treat the call as a jump (correction: false). The snippets below do this.
When the Hook Is Called
- The jump. Once the post is on the page, the embed calls the hook with
correction: false. If the post is not loaded yet, the embed loads it first. A two-parameter hook always gets this call first, even when the post is already in place. - Corrections. While the jump is held, the embed watches the layout. Content above the post can change height, for example images, social media embeds, or posts loaded above it. When that moves
topby more than 5 px since the last value sent, the embed calls the hook again. The call carries the newtop,correction: trueand the samejumpId. The embed calls the hook at most once per frame.
The jump is held until the first of these:
- 10 s have passed since the jump;
- a new jump starts: no call for the old
jumpIdfollows the first call of the new one; - the reader touches, clicks, turns the mouse wheel or presses a key inside the web page;
- the liveblog is removed from the page;
- for a one-parameter hook, 1 s has passed since the last call.
Changes after the hold has ended are not reported.
Also note:
- Define the hook before the first jump and keep it. The embed decides when a jump starts whether to call the hook. If the hook is missing at that moment, the embed scrolls the web page itself, which does nothing in a web view sized to its content. A hook defined later, while that jump is still held, receives it as a new jump with a new
jumpId. Removing the hook while a jump is held ends the hold. - Errors are contained. If your hook throws, the embed logs the error to the console, keeps holding the jump and still sends later corrections.
- The embed keeps scrolling its own scroll areas. The hook only replaces the page scroll. The embed still scrolls its own scroll areas itself, such as the strip of posts in the horizontal timeline layout.
What Your App Is Responsible For
- Stop following corrections once the reader scrolls. The embed ends the hold on touches that reach the web page. It never sees a drag that starts on your native rows or that your scroll view takes over. Remember the
jumpIdof the last jump call. Once the reader starts dragging your native view, drop further corrections for thatjumpId, or your list pulls the reader back to the post. - Clamp to your scroll range. A post near the end of your content cannot be scrolled to the top. Clamp the target to your scroll view’s range so the list does not overscroll. The web page can report a new
topbefore your web view has grown to its new content height. Near the end of the list, that scroll runs out of room. Re-apply the jump’s last target once the web view has its new height, as both sample apps do. - Subtract your native insets.
topdoes not know about your navigation bar, toolbar or other overlays. Subtract their height, or the post lands underneath them. Do this in your app rather than throughtopOffset:topOffsetapplies only to that onenavigateToEventIdcall, not to highlight taps or deep links.
Both sample apps animate the jump. The Android sample applies a correction without animation once the list has stopped scrolling, so the post stays still while content above it grows.
iOS
Define the hook in a WKUserScript that is injected at document start, so that it exists before the first jump. Post its arguments to a script message handler (EmbedCell.swift and ScriptMessageHandler.swift in the sample):
struct ScrollRequest {
let top: CGFloat
let correction: Bool
let jumpId: Int
}
let script = """
window.tickarooLiveblogScrollTo = function (top, info) {
window.webkit.messageHandlers.scrollToSwift.postMessage({
"top": top,
"correction": !!(info && info.correction),
"jumpId": (info && info.jumpId) || 0
});
};
"""
contentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
contentController.add(self, name: "scrollToSwift")
// in the message handler's callback; `dict` is the message body
guard let top = dict["top"] as? CGFloat else { return }
scrollCallback?(.init(
top: top,
correction: dict["correction"] as? Bool ?? false,
jumpId: dict["jumpId"] as? Int ?? 0
))
In CollectionViewDemo.swift, the sample gates corrections on the reader’s drag, clamps the target and subtracts the insets. When the cell resizes, it restores the content offset and then re-applies the jump if the jump is still animating or was clamped by the old content size:
private var activeJumpId: Int?
private var requestedPostY: CGFloat = 0
private var isJumpAnimating = false
private var isJumpClamped = false
private func scrollToRequestedPost() {
// contentSize is not updated yet when performBatchUpdates completes
collectionView.layoutIfNeeded()
let inset = collectionView.adjustedContentInset
let minY = -inset.top
let maxY = max(minY, collectionView.contentSize.height + inset.bottom - collectionView.bounds.height)
let desiredY = requestedPostY - inset.top
let target = CGPoint(x: collectionView.contentOffset.x, y: min(max(desiredY, minY), maxY))
// A correction can arrive before the embed cell has grown, so the next resize re-applies a clamped target
isJumpClamped = desiredY > maxY
// scrollViewDidEndScrollingAnimation is not called for an animation to the current offset,
// but it is called for the running animation when a new one replaces it, so the flag is set afterwards
let animated = abs(target.y - collectionView.contentOffset.y) >= 1
collectionView.setContentOffset(target, animated: animated)
isJumpAnimating = animated
}
cell.resizeCallback = { [weak self] newHeight in
let offset = self?.collectionView.contentOffset ?? .zero
// Restoring the offset stops a jump mid-animation and keeps a clamped jump short, so the jump is re-aimed afterwards
let reaimJump = (self?.isJumpAnimating ?? false) || (self?.isJumpClamped ?? false)
self?.embedHeight = newHeight
UIView.setAnimationsEnabled(false)
self?.collectionView.performBatchUpdates(nil, completion: { _ in
UIView.setAnimationsEnabled(true)
self?.collectionView.setContentOffset(offset, animated: false)
if reaimJump, self?.activeJumpId != nil {
self?.scrollToRequestedPost()
}
})
}
cell.scrollCallback = { [weak self, weak cell] request in
guard let self, let cell else { return }
// Corrections only belong to the latest jump, and the reader's drag ends them
let dropped = request.correction && request.jumpId != self.activeJumpId
guard !dropped else { return }
self.activeJumpId = request.jumpId
self.requestedPostY = cell.frame.minY + request.top
self.scrollToRequestedPost()
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
activeJumpId = nil
isJumpAnimating = false
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
isJumpAnimating = false
}
Android
Enable JavaScript and DOM storage in the web view’s settings (javaScriptEnabled = true, domStorageEnabled = true). The liveblog requires both.
The JavaScript bridge matches a @JavascriptInterface method by its number of arguments. The shim must therefore always pass exactly as many values as the Kotlin method declares. It declares info, so that the embed passes it, and forwards three fixed values. Inject it in onPageStarted, so that it exists before a deep link at load. The sample injects it again in onPageFinished (LiveblogEmbedRecyclerViewFragment.kt):
webView.evaluateJavascript(
"window.tickarooLiveblogScrollTo = function(top, info) { tik4Android.tickarooLiveblogScrollTo(top, !!(info && info.correction), info ? info.jumpId : 0) }",
null,
)
The bridge method is in LiveblogWebAppInterface.kt and is registered as tik4Android. Bridge methods run on a background thread, so the callback moves to the UI thread with webView.post:
class LiveblogWebAppInterface(
private val scrollCallback: (top: Double, correction: Boolean, jumpId: Int) -> Unit,
) {
// top is a Double because the JS bridge truncates a number passed to an int parameter,
// and the lost fraction would then be multiplied by the display density.
@JavascriptInterface
fun tickarooLiveblogScrollTo(top: Double, correction: Boolean, jumpId: Int) {
scrollCallback(top, correction, jumpId)
}
}
webView.addJavascriptInterface(
LiveblogWebAppInterface(scrollCallback = { top, correction, jumpId ->
webView.post { scrollToPost(top, correction, jumpId) }
}),
"tik4Android",
)
The view holder gates corrections on the reader’s drag, clamps to the scroll range and scrolls the RecyclerView. LinearLayoutManager only estimates the scroll range, and the web view row grows after the page reports a new top. So the view holder finishes the scroll once the row has its new height:
// smoothScrollBy reports SETTLING, so DRAGGING only ever comes from the reader's own touch.
class DragCounter : RecyclerView.OnScrollListener() {
var count = 0
private set
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) count++
}
}
private var jumpId: Int? = null
private var dragCountAtJump = 0
private var pendingTop: Double? = null
private var pendingUntil = 0L
private var aimedWebViewTop: Int? = null
init {
// The page reports a new top before this row has grown to the new content height, so near the end of the list
// a scroll can run out of room. It is finished once the row has its new height.
webView.addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
if (bottom - top != oldBottom - oldTop) webView.post { settle() }
}
}
private fun scrollToPost(top: Double, correction: Boolean, jumpId: Int) {
if (!correction || jumpId != this.jumpId) {
this.jumpId = jumpId
dragCountAtJump = dragCounter.count
} else if (dragCounter.count != dragCountAtJump) {
return
}
pendingTop = top
pendingUntil = SystemClock.uptimeMillis() + HOLD_MS
scrollTo(top, correction)
}
private fun settle() {
val top = pendingTop ?: return
if (dragCounter.count != dragCountAtJump || SystemClock.uptimeMillis() > pendingUntil) {
pendingTop = null
return
}
if (scrollTo(top, correction = true) == 0) pendingTop = null
}
private fun scrollTo(top: Double, correction: Boolean): Int {
val recyclerView = webView.parent as? RecyclerView ?: return 0
val offsetViewBounds = Rect()
webView.getDrawingRect(offsetViewBounds)
recyclerView.offsetDescendantRectToMyCoords(webView, offsetViewBounds)
val topInPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, top.toFloat(), webView.context.resources.displayMetrics)
val requested = offsetViewBounds.top + topInPx.roundToInt()
// LinearLayoutManager estimates these from the visible rows; they are exact once the first or last row is visible.
val offset = recyclerView.computeVerticalScrollOffset()
val maxOffset = recyclerView.computeVerticalScrollRange() - recyclerView.computeVerticalScrollExtent()
val scrollBy = (offset + requested).coerceIn(0, maxOf(maxOffset, 0)) - offset
// Re-aiming restarts the smooth scroll's easing, so a scroll already heading for this position is left alone.
val settling = recyclerView.scrollState == RecyclerView.SCROLL_STATE_SETTLING
val aim = offsetViewBounds.top - scrollBy
if (settling && aim == aimedWebViewTop) return scrollBy
aimedWebViewTop = aim
// Once the jump has landed, an instant scroll keeps the post still while content above it grows.
if (correction && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
recyclerView.scrollBy(0, scrollBy)
} else {
recyclerView.smoothScrollBy(0, scrollBy)
}
return scrollBy
}
companion object {
// The embed sends corrections for at most 10 s after the jump.
private const val HOLD_MS = 10_000L
}
Register the DragCounter with recyclerView.addOnScrollListener. The sample subtracts no inset, and the post lands at the list’s top edge. If your list draws under a toolbar or status bar, subtract that height from requested.
Jumping to a Post from Your App
With navigateToEventId
Give the tag an id, for example <tickaroo-liveblog id="my-liveblog" …>. Wait until the liveblog has initialized, which fires the tickarooLiveblogInit event (see Liveblog Tag Events). Then call navigateToEventId through your web view. The jump reaches your hook like any other.
// iOS
webView.evaluateJavaScript("document.getElementById('my-liveblog').navigateToEventId('EVENT_ID')")
// Android
webView.evaluateJavascript("document.getElementById('my-liveblog').navigateToEventId('EVENT_ID')", null)
navigateToEventId returns a Promise. On iOS, evaluateJavaScript does not pass its result back to your app. To get the result, call callAsyncJavaScript with the function body return await document.getElementById('my-liveblog').navigateToEventId('EVENT_ID').
With a #tickaroo_event_id Deep Link
Add #tickaroo_event_id=EVENT_ID to the URL your web view loads. The embed reads it when the liveblog initializes and again whenever the fragment changes to another post. A fragment change only jumps to a post the liveblog can find; it does not report back whether it did, so use navigateToEventId when your app needs the result. For how the embed loads a post that is not on the page yet, and the deepLinkLimit option that controls it, see Deep Linking.
// Android
webView.loadUrl("https://www.example.com/liveblog.html#tickaroo_event_id=EVENT_ID")
// iOS, hosted page
webView.load(URLRequest(url: URL(string: "https://www.example.com/liveblog.html#tickaroo_event_id=EVENT_ID")!))
// iOS, local HTML: the fragment on the base URL reaches the page
webView.loadHTMLString(embedHtml, baseURL: URL(string: "https://www.example.com/#tickaroo_event_id=EVENT_ID"))
The page’s origin must be one of your Enabled domains, see App Integration.
Sample Apps
Both sample apps show the hook in a web view inside a native list:
- iOS, liveblog-embed-demo-ios:
- the hook script is in
EmbedCell.swift; - the drag gate, clamping, insets and resize handling are in
CollectionViewDemo.swift.
- the hook script is in
- Android, liveblog-embed-demo-android:
- the shim, drag gate and clamping are in
LiveblogEmbedRecyclerViewFragment.kt; - the bridge method is in
LiveblogWebAppInterface.kt.
- the shim, drag gate and clamping are in