From b5a96ad1d0a412c21159d6265e451f7c3aefc796 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 15 Sep 2026 23:12:44 +0000 Subject: Support pinch-to-zoom for reader text size in the article view Pinching in ReaderScreen's article WebView now adjusts the same persisted reader font size the Settings slider controls, instead of doing nothing (WebView's own page zoom was never enabled, so a pinch previously had no effect at all). A ScaleGestureDetector is fed through setOnTouchListener, which always returns false so it never consumes the touch -- WebView's existing one-finger scrolling, link taps and long-press-link handling are unaffected, since ScaleGestureDetector only fires once a second pointer is down. During the gesture the WebView is scaled live via View.scaleX/ scaleY for immediate visual feedback; at gesture end the scale resets to 1x and the computed, clamped font size is persisted, which drives ReaderScreen's existing content-reload-on-fontSizePx-change path to re-render the actual HTML at the new size. TDD: pinchZoomFontSizePx (the pure scale-factor -> clamped-px calculation) was written test-first, red on a TODO() stub, green on the real implementation -- covered by SettingsTest. newReaderWebView grew from 6 to 8 parameters to carry the new gesture hooks, which pushed it over detekt's LongParameterList threshold; bundled all of its callbacks into a ReaderWebViewCallbacks data class rather than baselining past it, updating both call sites (ReaderScreen, LinkReaderScreen). ReaderContent gained no new parameter -- it now independently remembers its own Settings instance to persist the pinch-committed size, matching how it already remembers its own db and scope rather than threading them from ReaderScreen. LinkReaderScreen's WebView is untouched: it doesn't read the reader font size setting at all today, so wiring pinch-zoom there would be a separate, unrelated fix. --- .../java/org/terst/feedreader/data/Settings.kt | 7 ++ .../org/terst/feedreader/ui/LinkReaderScreen.kt | 8 +- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 30 ++++++-- .../java/org/terst/feedreader/ui/ReaderWebView.kt | 87 ++++++++++++++++++---- 4 files changed, 108 insertions(+), 24 deletions(-) (limited to 'app/src/main') diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt index be3e809..ebeba6c 100644 --- a/app/src/main/java/org/terst/feedreader/data/Settings.kt +++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import kotlin.math.roundToInt val Context.settingsDataStore by preferencesDataStore(name = "settings") @@ -38,6 +39,12 @@ private const val READER_FONT_SIZE_MIN_PX = 14 private const val READER_FONT_SIZE_MAX_PX = 28 val READER_FONT_SIZE_RANGE_PX = READER_FONT_SIZE_MIN_PX..READER_FONT_SIZE_MAX_PX +// Pure so the pinch-to-zoom math is testable without a WebView/gesture detector in the loop -- +// ReaderWebView.kt calls this once per gesture (at pinch-end, with the cumulative scale factor +// accumulated across the whole gesture), not per touch-move frame. +fun pinchZoomFontSizePx(basePx: Int, scaleFactor: Float): Int = + (basePx * scaleFactor).roundToInt().coerceIn(READER_FONT_SIZE_RANGE_PX.first, READER_FONT_SIZE_RANGE_PX.last) + /** Stash connection settings, configured on the Settings screen. Both blank until set. */ class Settings(private val context: Context) { val stashBaseUrl: Flow = diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt index 0f7540d..d71b020 100644 --- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt @@ -234,9 +234,11 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un factory = { ctx -> newReaderWebView( ctx, - onOpenLink, - onLongPressLink = { longPressedLink = it }, - onYouTubeVideo = { youtubeVideoId = it } + ReaderWebViewCallbacks( + onOpenLink = onOpenLink, + onLongPressLink = { longPressedLink = it }, + onYouTubeVideo = { youtubeVideoId = it } + ) ).apply { loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null) loadedContent.value = currentExtractedContent diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt index a89052c..e2b8aa9 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -180,6 +181,10 @@ private fun ReaderContent( ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } + // Independently remembered rather than threaded down from ReaderScreen, matching db/scope + // above -- Settings is a stateless DataStore wrapper, cheap to construct again, and this + // keeps pinch-to-zoom from growing ReaderContent's already-long parameter list any further. + val settings = remember { Settings(context) } val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -526,19 +531,28 @@ private fun ReaderContent( // changing it in Settings and coming back to an already-open article // picks up the new size, without reloading on every unrelated recomposition. val loadedKey = remember(articleId) { mutableStateOf?>(null) } + // newReaderWebView's factory runs once per WebView instance, but fontSizePx can + // change later (e.g. the user tweaks it in Settings while this same article stays + // open) -- rememberUpdatedState keeps the pinch gesture's "what am I scaling from" + // read current instead of frozen at whatever it was on first composition. + val latestFontSizePx by rememberUpdatedState(fontSizePx) AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> newReaderWebView( ctx, - onOpenLink, - onLongPressLink = { longPressedLink = it }, - onYouTubeVideo = { youtubeVideoId = it }, - onScrollChanged = { scrollY -> ReaderScrollPositions.set(articleId, scrollY) }, - // The page's real content height isn't known until layout finishes - // -- scrolling to a saved offset any earlier would just clamp back - // to 0 on a page that hasn't grown tall enough yet. - onPageFinished = { webView -> webView.scrollTo(0, ReaderScrollPositions.get(articleId)) } + ReaderWebViewCallbacks( + onOpenLink = onOpenLink, + onLongPressLink = { longPressedLink = it }, + onYouTubeVideo = { youtubeVideoId = it }, + onScrollChanged = { scrollY -> ReaderScrollPositions.set(articleId, scrollY) }, + // The page's real content height isn't known until layout finishes + // -- scrolling to a saved offset any earlier would just clamp back + // to 0 on a page that hasn't grown tall enough yet. + onPageFinished = { webView -> webView.scrollTo(0, ReaderScrollPositions.get(articleId)) }, + currentFontSizePx = { latestFontSizePx }, + onFontSizeChanged = { newSizePx -> scope.launch { settings.setReaderFontSizePx(newSizePx) } } + ) ).apply { val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx) loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null) diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt index b13fc58..039255f 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -3,6 +3,7 @@ package org.terst.feedreader.ui import android.content.Context import android.content.Intent import android.net.Uri +import android.view.ScaleGestureDetector import android.view.View import android.webkit.WebChromeClient import android.webkit.WebResourceRequest @@ -23,6 +24,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX +import org.terst.feedreader.data.pinchZoomFontSizePx // In-memory only (process lifetime, not persisted to disk) -- scoped to "don't lose my // place if Next/Previous takes me away and back," not to surviving an app restart. A @@ -76,15 +78,44 @@ private fun extractYouTubeVideoId(uri: Uri): String? { * YouTubePlayerDialog instead, so watching a video doesn't leave the reader. Falls * back to the external-open behavior when a confident video ID can't be extracted * (a playlist/channel link, say). + * + * Pinch-to-zoom adjusts the persisted reader font size, not WebView's own page zoom + * (never enabled here -- setSupportZoom/builtInZoomControls are left at their defaults, + * which is effectively off). A real page zoom would scale layout and images along with + * text and wouldn't survive the next article/reload; this app already has a font-size + * concept the rest of the reader respects (Settings.readerFontSizePx, adjustable from + * the Settings screen), so pinching should drive that same value instead of inventing a + * second, WebView-local notion of "how big does this look right now." + * + * Getting this right without breaking ordinary one-finger scrolling/tapping is the whole + * trick: a ScaleGestureDetector is fed every touch via setOnTouchListener, but that + * listener always returns false (never consumes the event), so WebView's own touch + * handling -- scrolling, link taps, the long-press-link listener above -- proceeds + * exactly as before. ScaleGestureDetector only calls back once a second pointer is down, + * so a single-finger drag never triggers any of this. + * + * currentFontSizePx is read fresh (not captured once) when a gesture ends, since the + * WebView this attaches to is created once but can outlive a Settings-screen-driven font + * size change made while this same article is still open. + * + * During the gesture the WebView is visually scaled live (View.scaleX/scaleY) for + * immediate feedback -- cheap, and independent of the WebView's own content layout. + * At gesture end the scale resets to 1x and onFontSizeChanged reports the new, + * clamped, rounded font size for the caller to persist; ReaderScreen's existing + * content-vs-fontSizePx reload logic (see ReaderScreen.ReaderContent) then re-renders + * the actual HTML at that size. */ -fun newReaderWebView( - context: Context, - onOpenLink: (String) -> Unit, - onLongPressLink: (String) -> Unit = {}, - onYouTubeVideo: (String) -> Unit = {}, - onScrollChanged: (Int) -> Unit = {}, - onPageFinished: (WebView) -> Unit = {} -): WebView = +data class ReaderWebViewCallbacks( + val onOpenLink: (String) -> Unit, + val onLongPressLink: (String) -> Unit = {}, + val onYouTubeVideo: (String) -> Unit = {}, + val onScrollChanged: (Int) -> Unit = {}, + val onPageFinished: (WebView) -> Unit = {}, + val currentFontSizePx: () -> Int = { DEFAULT_READER_FONT_SIZE_PX }, + val onFontSizeChanged: (Int) -> Unit = {} +) + +fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebView = WebView(context).apply { settings.javaScriptEnabled = false overScrollMode = View.OVER_SCROLL_NEVER @@ -95,11 +126,41 @@ fun newReaderWebView( // Next/Previous replaces this screen's back-stack entry with a fresh one for the same // article, which recreates the WebView from scratch and would otherwise always land // back at the top. - setOnScrollChangeListener { _, _, scrollY, _, _ -> onScrollChanged(scrollY) } + setOnScrollChangeListener { _, _, scrollY, _, _ -> callbacks.onScrollChanged(scrollY) } + + var pinchScale = 1f + val webView = this + val scaleDetector = ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + pinchScale = 1f + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + pinchScale *= detector.scaleFactor + webView.scaleX = pinchScale + webView.scaleY = pinchScale + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + val newSizePx = pinchZoomFontSizePx(callbacks.currentFontSizePx(), pinchScale) + webView.scaleX = 1f + webView.scaleY = 1f + callbacks.onFontSizeChanged(newSizePx) + } + } + ) + setOnTouchListener { _, event -> + scaleDetector.onTouchEvent(event) + false // never consume -- WebView still handles scroll/tap/long-press normally + } webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) - onPageFinished(view) + callbacks.onPageFinished(view) } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { @@ -117,12 +178,12 @@ fun newReaderWebView( if (isYouTubeWatchUrl(url)) { val videoId = extractYouTubeVideoId(url) if (videoId != null) { - onYouTubeVideo(videoId) + callbacks.onYouTubeVideo(videoId) } else { context.startActivity(Intent(Intent.ACTION_VIEW, url)) } } else { - onOpenLink(url.toString()) + callbacks.onOpenLink(url.toString()) } return true } @@ -134,7 +195,7 @@ fun newReaderWebView( else -> null } if (url != null) { - onLongPressLink(url) + callbacks.onLongPressLink(url) true } else { false -- cgit v1.2.3