refactor tab bar behaviour; respect scroll preferences of websites;

This commit is contained in:
Fabian Freund
2026-02-19 06:20:08 +01:00
parent 4d7c185460
commit 3025d6e42d
21 changed files with 602 additions and 195 deletions
@@ -26,6 +26,7 @@ import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActi
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
import eu.weblibre.flutter_mozilla_components.feature.BrowserHandlingScrollFeature
import eu.weblibre.flutter_mozilla_components.feature.KeyboardVisibilityFeature
import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature
import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature
@@ -97,6 +98,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
// Keyboard visibility detection feature
private var keyboardVisibilityFeature: KeyboardVisibilityFeature? = null
// Browser scroll-handling detection feature
private var browserHandlingScrollFeature: BrowserHandlingScrollFeature? = null
// Registers a photo picker activity launcher in single-select mode.
private val singleMediaPicker =
AndroidPhotoPicker.singleMediaPicker(
@@ -500,6 +504,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
keyboardVisibilityFeature = KeyboardVisibilityFeature(viewportEvents).also {
it.start(binding.root)
}
browserHandlingScrollFeature = BrowserHandlingScrollFeature(viewportEvents).also {
it.start()
}
}
onEngineSetupComplete()
@@ -614,6 +622,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
keyboardVisibilityFeature?.stop()
keyboardVisibilityFeature = null
// Stop browser scroll-handling detection
browserHandlingScrollFeature?.stop()
browserHandlingScrollFeature = null
GlobalComponents.onPullToRefreshEnabledChanged = null
val engineView = fragmentEngineView
engineView?.setActivityContext(null)
@@ -216,7 +216,8 @@ class GeckoTabsApiImpl : GeckoTabsApi {
progress = tab.content.progress.toLong(),
isPrivate = tab.content.private,
isFullScreen = tab.content.fullScreen,
isLoading = tab.content.loading
isLoading = tab.content.loading,
showToolbarAsExpanded = tab.content.showToolbarAsExpanded,
)
) { }
}
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.feature.addons.logger
@@ -188,7 +189,8 @@ class Events(
it.content.private,
it.content.fullScreen,
it.content.progress,
it.content.loading
it.content.loading,
it.content.showToolbarAsExpanded,
)
}
.debounce(15)
@@ -204,9 +206,19 @@ class Events(
progress = tab.content.progress.toLong(),
isPrivate = tab.content.private,
isFullScreen = tab.content.fullScreen,
isLoading = tab.content.loading
isLoading = tab.content.loading,
showToolbarAsExpanded = tab.content.showToolbarAsExpanded,
)
) { _ -> }
// Reset showToolbarAsExpanded after forwarding to Flutter,
// mirroring Fenix ToolbarBehaviorController behavior.
// This ensures subsequent expand events trigger a new state change.
if (tab.content.showToolbarAsExpanded) {
stateFlow.dispatch(
ContentAction.UpdateExpandedToolbarStateAction(tab.id, false)
)
}
}
}
@@ -0,0 +1,124 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.feature
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import org.mozilla.gecko.util.ThreadUtils.runOnUiThread
/**
* Feature that reports whether GeckoView is currently handling scroll input.
*
* Checks InputResultDetail while active and only emits on state changes.
*/
class BrowserHandlingScrollFeature(
private val flutterEvents: GeckoViewportEvents,
) {
private var running = false
private var touchSessionActive = false
private var lastValue: Boolean? = null
private var touchListener: View.OnTouchListener? = null
private var touchTargetView: View? = null
fun start() {
if (running) return
running = true
touchSessionActive = false
lastValue = null
attachTouchListenerIfPossible()
}
fun stop() {
if (!running) return
running = false
touchSessionActive = false
detachTouchListenerIfPossible()
lastValue = null
}
private fun attachTouchListenerIfPossible() {
val engineViewRoot = GlobalComponents.components?.mainBrowserEngineView?.asView()
if (engineViewRoot == null) return
if (touchListener != null) return
val targetView = resolveTouchTargetView(engineViewRoot)
touchListener = View.OnTouchListener { _, event ->
if (!running) return@OnTouchListener false
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
touchSessionActive = true
emitIfChanged()
}
MotionEvent.ACTION_MOVE -> {
if (touchSessionActive) {
emitIfChanged()
}
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
touchSessionActive = false
}
}
// Never consume touch; GeckoView must keep handling it.
false
}
targetView.setOnTouchListener(touchListener)
touchTargetView = targetView
}
private fun detachTouchListenerIfPossible() {
if (touchTargetView != null && touchListener != null) {
touchTargetView?.setOnTouchListener(null)
}
touchListener = null
touchTargetView = null
}
private fun resolveTouchTargetView(root: View): View {
if (root !is ViewGroup) return root
// GeckoEngineView wraps the actual NestedGeckoView as its first child.
if (root.childCount > 0) {
return root.getChildAt(0)
}
return root
}
private fun emitIfChanged() {
val isHandling = try {
val engineView = GlobalComponents.components?.mainBrowserEngineView
if (engineView == null) {
false
} else {
val detail = engineView.getInputResultDetail()
detail.canScrollToTop() || detail.canScrollToBottom()
}
} catch (_: Throwable) {
false
}
if (lastValue == isHandling) return
lastValue = isHandling
val sequence = EventSequence.next()
runOnUiThread {
flutterEvents.onBrowserHandlingScrollChanged(sequence, isHandling) { }
}
}
}
@@ -1597,7 +1597,8 @@ data class TabContentState (
val progress: Long,
val isPrivate: Boolean,
val isFullScreen: Boolean,
val isLoading: Boolean
val isLoading: Boolean,
val showToolbarAsExpanded: Boolean
)
{
companion object {
@@ -1611,7 +1612,8 @@ data class TabContentState (
val isPrivate = pigeonVar_list[6] as Boolean
val isFullScreen = pigeonVar_list[7] as Boolean
val isLoading = pigeonVar_list[8] as Boolean
return TabContentState(id, parentId, contextId, url, title, progress, isPrivate, isFullScreen, isLoading)
val showToolbarAsExpanded = pigeonVar_list[9] as Boolean
return TabContentState(id, parentId, contextId, url, title, progress, isPrivate, isFullScreen, isLoading, showToolbarAsExpanded)
}
}
fun toList(): List<Any?> {
@@ -1625,6 +1627,7 @@ data class TabContentState (
isPrivate,
isFullScreen,
isLoading,
showToolbarAsExpanded,
)
}
override fun equals(other: Any?): Boolean {
@@ -6919,6 +6922,31 @@ class GeckoViewportEvents(private val binaryMessenger: BinaryMessenger, private
}
}
}
/**
* Called when GeckoView scroll-handling eligibility changes.
*
* [sequence] Event sequence number for ordering.
* [isHandling] True when browser content can consume scrolling for
* dynamic toolbar behavior. False when content is not scrollable or
* the page consumed touch input.
*/
fun onBrowserHandlingScrollChanged(sequenceArg: Long, isHandlingArg: Boolean, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onBrowserHandlingScrollChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(sequenceArg, isHandlingArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoBookmarksApi {