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

  • top is 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 and top is 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 topOffset passed to navigateToEventId. 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.
  • info is an object { correction, jumpId }.
    • correction (boolean) is false for the first call of a jump and true for 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 passes top and info, and corrections can arrive for up to 10 s after the jump.
  • One parameter, function (top): every call passes top alone, 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

  1. 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.
  2. 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 top by more than 5 px since the last value sent, the embed calls the hook again. The call carries the new top, correction: true and the same jumpId. 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 jumpId follows 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 jumpId of the last jump call. Once the reader starts dragging your native view, drop further corrections for that jumpId, 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 top before 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. top does 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 through topOffset: topOffset applies only to that one navigateToEventId call, 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').

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: