gesture feature initial
This commit is contained in:
+3
-3
@@ -55,7 +55,7 @@ import mozilla.components.feature.prompts.file.AndroidPhotoPicker
|
||||
import mozilla.components.feature.session.FullScreenFeature
|
||||
import mozilla.components.feature.session.PictureInPictureFeature
|
||||
import mozilla.components.feature.session.SessionFeature
|
||||
import mozilla.components.feature.session.SwipeRefreshFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.GestureAwareSwipeRefreshFeature
|
||||
import mozilla.components.feature.sitepermissions.SitePermissionsFeature
|
||||
import mozilla.components.feature.sitepermissions.SitePermissionsRules
|
||||
import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
|
||||
@@ -87,7 +87,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
|
||||
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
|
||||
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
|
||||
private val swipeRefreshFeature = ViewBoundFeatureWrapper<SwipeRefreshFeature>()
|
||||
private val swipeRefreshFeature = ViewBoundFeatureWrapper<GestureAwareSwipeRefreshFeature>()
|
||||
private val secureWindowFeature = ViewBoundFeatureWrapper<SecureWindowFeature>()
|
||||
private val fullScreenFeature = ViewBoundFeatureWrapper<FullScreenFeature>()
|
||||
private val mediaSessionFullscreenFeature =
|
||||
@@ -272,7 +272,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
)
|
||||
|
||||
swipeRefreshFeature.set(
|
||||
feature = SwipeRefreshFeature(
|
||||
feature = GestureAwareSwipeRefreshFeature(
|
||||
components.core.store,
|
||||
components.useCases.sessionUseCases.reload,
|
||||
binding.swipeToRefresh,
|
||||
|
||||
+34
@@ -14,12 +14,14 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMo
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
|
||||
import eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService
|
||||
@@ -97,6 +99,38 @@ object GlobalComponents {
|
||||
// Engine settings API for managing engine-specific settings
|
||||
var engineSettingsApi: GeckoEngineSettingsApiImpl? = null
|
||||
|
||||
// Touch-gesture recognition: event sink (Kotlin → Dart) and the current
|
||||
// configuration pushed from Dart. Read by the browser container's
|
||||
// GestureRecognizer on the UI thread.
|
||||
var gestureEvents: GeckoGestureEvents? = null
|
||||
|
||||
@Volatile
|
||||
var gestureConfig: GestureConfig? = null
|
||||
|
||||
/**
|
||||
* Set true when the in-flight touch sequence was recognized as a configured
|
||||
* gesture, so pull-to-refresh ([GestureAwareSwipeRefreshFeature]) can
|
||||
* suppress the otherwise-redundant reload for down-leading gestures started
|
||||
* at the top of the page. Reset on each ACTION_DOWN by the gesture
|
||||
* container. Read and written on the UI thread.
|
||||
*/
|
||||
@Volatile
|
||||
var touchConsumedByGesture: Boolean = false
|
||||
|
||||
// Current dynamic-toolbar viewport insets (physical px), tracked from the
|
||||
// viewport API so gesture edge-detection can exclude the bottom toolbar
|
||||
// area the engine view never receives touches in.
|
||||
@Volatile
|
||||
var dynamicToolbarMaxHeightPx: Int = 0
|
||||
|
||||
@Volatile
|
||||
var verticalClippingPx: Int = 0
|
||||
|
||||
/** Currently visible bottom inset: full toolbar height when shown, 0 when
|
||||
* auto-hidden (clipping cancels it out). */
|
||||
val bottomViewportInsetPx: Int
|
||||
get() = (dynamicToolbarMaxHeightPx + verticalClippingPx).coerceAtLeast(0)
|
||||
|
||||
// External download manager setting
|
||||
var useExternalDownloadManager: Boolean = false
|
||||
|
||||
|
||||
+10
@@ -41,6 +41,8 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoIconsApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPublicSuffixListApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSitePermissionsApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTrackingProtectionApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
||||
@@ -337,6 +339,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
GlobalComponents.viewportEvents = viewportEvents
|
||||
GlobalComponents.viewportApi = viewportApi
|
||||
|
||||
// Touch-gesture recognition: event sink (Kotlin → Dart) + config API
|
||||
GlobalComponents.gestureEvents =
|
||||
GeckoGestureEvents(_flutterPluginBinding.binaryMessenger)
|
||||
GeckoGestureApi.setUp(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
GeckoGestureApiImpl()
|
||||
)
|
||||
|
||||
ReaderViewEvents.setUp(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
components.events.readerViewEvents
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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.api
|
||||
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig
|
||||
|
||||
/**
|
||||
* Stores the gesture-recognition configuration pushed from Dart. The browser
|
||||
* container's [eu.weblibre.flutter_mozilla_components.feature.GestureRecognizer]
|
||||
* reads it on the UI thread for every touch event.
|
||||
*/
|
||||
class GeckoGestureApiImpl : GeckoGestureApi {
|
||||
override fun setGestureConfig(config: GestureConfig) {
|
||||
GlobalComponents.gestureConfig = config
|
||||
}
|
||||
}
|
||||
+2
@@ -36,6 +36,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
|
||||
*/
|
||||
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
|
||||
val height = heightPx.toInt()
|
||||
GlobalComponents.dynamicToolbarMaxHeightPx = height
|
||||
|
||||
val engineView = components.mainBrowserEngineView
|
||||
if (engineView == null) {
|
||||
@@ -65,6 +66,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
|
||||
*/
|
||||
override fun setVerticalClipping(clippingPx: Long) {
|
||||
val clipping = clippingPx.toInt()
|
||||
GlobalComponents.verticalClippingPx = clipping
|
||||
|
||||
val engineView = components.mainBrowserEngineView
|
||||
if (engineView == null) {
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/* 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 http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package eu.weblibre.flutter_mozilla_components.feature
|
||||
|
||||
import android.os.Build
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.map
|
||||
import mozilla.components.browser.state.action.ContentAction.UpdateRefreshCanceledStateAction
|
||||
import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.concept.engine.EngineView
|
||||
import mozilla.components.feature.session.SessionUseCases
|
||||
import mozilla.components.lib.state.ext.flowScoped
|
||||
import mozilla.components.support.base.feature.LifecycleAwareFeature
|
||||
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
|
||||
|
||||
/**
|
||||
* A gesture-aware variant of Mozilla's `SwipeRefreshFeature`.
|
||||
*
|
||||
* Behaves exactly like the upstream feature (coordinates a [SwipeRefreshLayout]
|
||||
* with the session's loading state and reloads on a pull-down at the top of the
|
||||
* page), with one addition: if the same touch sequence was recognized as a
|
||||
* configured touch gesture, the reload is suppressed.
|
||||
*
|
||||
* Why: the gesture recognizer in `BackGestureFilterFrameLayout` is purely
|
||||
* observational (it never consumes events), so a down-leading gesture — e.g.
|
||||
* `D-R` (back) or `D-R-U` (reload) — that starts at the top of the page also
|
||||
* drives the pull-to-refresh throbber and would otherwise fire a redundant
|
||||
* reload on release. This mirrors the reference add-on's pull-to-refresh
|
||||
* `continue()`/`end()` guards, which let pull-to-refresh act only as the
|
||||
* fallback for a plain straight-down pull that matches no gesture.
|
||||
*
|
||||
* The recognizer flags [GlobalComponents.touchConsumedByGesture] on the
|
||||
* terminating ACTION_UP (which dispatches to the ancestor container before this
|
||||
* layout's own up-handling runs [onRefresh]), so the flag is reliably set by the
|
||||
* time we consult it here.
|
||||
*
|
||||
* Derived from android-components `SwipeRefreshFeature` (MPL-2.0).
|
||||
*/
|
||||
class GestureAwareSwipeRefreshFeature(
|
||||
private val store: BrowserStore,
|
||||
private val reloadUrlUseCase: SessionUseCases.ReloadUrlUseCase,
|
||||
private val swipeRefreshLayout: SwipeRefreshLayout,
|
||||
private val tabId: String? = null,
|
||||
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||
) : LifecycleAwareFeature,
|
||||
SwipeRefreshLayout.OnChildScrollUpCallback,
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
init {
|
||||
swipeRefreshLayout.setOnRefreshListener(this)
|
||||
swipeRefreshLayout.setOnChildScrollUpCallback(this)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
scope = store.flowScoped(dispatcher = mainDispatcher) { flow ->
|
||||
flow.map { state -> state.findTabOrCustomTabOrSelectedTab(tabId) }
|
||||
.ifAnyChanged {
|
||||
arrayOf(it?.content?.loading, it?.content?.refreshCanceled)
|
||||
}
|
||||
.collect { tab ->
|
||||
tab?.let {
|
||||
if (!tab.content.loading || tab.content.refreshCanceled) {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
if (tab.content.refreshCanceled) {
|
||||
store.dispatch(UpdateRefreshCanceledStateAction(tab.id, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
scope?.cancel()
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
override fun canChildScrollUp(parent: SwipeRefreshLayout, child: View?) =
|
||||
if (child is EngineView) {
|
||||
!child.getInputResultDetail().canOverscrollTop()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
// A configured touch gesture already handled this stroke; don't also
|
||||
// reload. Retract the throbber the layout showed during the pull.
|
||||
if (GlobalComponents.touchConsumedByGesture) {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
swipeRefreshLayout.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
|
||||
}
|
||||
store.state.findTabOrCustomTabOrSelectedTab(tabId)?.let { tab ->
|
||||
reloadUrlUseCase(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* 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 eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Pure, view-agnostic touch-gesture recognizer.
|
||||
*
|
||||
* Ported from the Simple Gesture add-on
|
||||
* (https://github.com/utubo/firefox-simple_gesture, MPL-2.0, Copyright 2017
|
||||
* utubo): the same stroke grammar is used so user configuration carries over.
|
||||
* A gesture is encoded as a canonical key built from three parts:
|
||||
*
|
||||
* - an optional **start-position** prefix describing where the touch began
|
||||
* (`L:`/`R:`/`T:`/`B:` for the four edges, `W:`/`E:` for the left/right
|
||||
* half otherwise),
|
||||
* - an optional **finger-count** prefix (`2:`, `3:` …; omitted for a single
|
||||
* finger), and
|
||||
* - the dash-joined sequence of dominant **directions** (`U`/`D`/`L`/`R`),
|
||||
*
|
||||
* e.g. `R:2:D-L` (two fingers, started at the right edge, moved down then
|
||||
* left) or simply `D-R`.
|
||||
*
|
||||
* Recognition is **observational**: callers feed every [MotionEvent] via [feed]
|
||||
* without consuming it, and [feed] returns the matched key on the terminating
|
||||
* `ACTION_UP` only when the assembled stroke matches an entry in
|
||||
* [GestureConfig.activeGestureKeys]. Pages therefore scroll and tap normally;
|
||||
* multi-stroke gestures simply fire their action on release.
|
||||
*/
|
||||
class GestureRecognizer {
|
||||
/**
|
||||
* Current configuration. Assigned by the owner (the browser container)
|
||||
* before each event so updates pushed from Dart take effect immediately.
|
||||
*/
|
||||
var config: GestureConfig? = null
|
||||
|
||||
/**
|
||||
* Invoked on the UI thread whenever a new direction arrow is appended to the
|
||||
* in-progress stroke, with the current partial canonical key (e.g. `R:D`).
|
||||
* The owner uses this both to restart the idle-timeout (matching the
|
||||
* reference add-on, which restarts only on a new arrow) and to drive the
|
||||
* live gesture-feedback overlay.
|
||||
*/
|
||||
var onProgress: ((String) -> Unit)? = null
|
||||
|
||||
private val arrows = ArrayList<Char>(MAX_ARROWS + 1)
|
||||
private var startPosition = ""
|
||||
private var fingers = ""
|
||||
private var fingersNum = 1
|
||||
private var lastX = 0f
|
||||
private var lastY = 0f
|
||||
private var lastArrow = ' '
|
||||
private var strokeSize = 0f
|
||||
private var edgeWidth = 0f
|
||||
private var contentBottom = 0f
|
||||
private var aborted = false
|
||||
private var active = false
|
||||
|
||||
/**
|
||||
* Feeds one motion event. Returns the recognized gesture key on the
|
||||
* terminating `ACTION_UP`, or null otherwise. Never consumes the event.
|
||||
*
|
||||
* [bottomInsetPx] is the height (physical px) of the dynamic bottom toolbar
|
||||
* currently overlaying the engine view. It is subtracted from the view
|
||||
* height so the bottom edge zone tracks the visible content edge rather than
|
||||
* the toolbar area (which never receives touches).
|
||||
*/
|
||||
fun feed(
|
||||
event: MotionEvent,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
bottomInsetPx: Int,
|
||||
): String? {
|
||||
val cfg = config
|
||||
if (cfg == null || !cfg.enabled || cfg.activeGestureKeys.isEmpty()) {
|
||||
active = false
|
||||
return null
|
||||
}
|
||||
|
||||
return when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
onDown(event, cfg, viewWidth, viewHeight, bottomInsetPx)
|
||||
null
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_POINTER_DOWN,
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
onMove(event, cfg)
|
||||
null
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> onUp(cfg)
|
||||
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
reset()
|
||||
null
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Discards any in-progress gesture (e.g. on idle timeout or back-gesture
|
||||
* interception). */
|
||||
fun cancel() = reset()
|
||||
|
||||
private fun onDown(
|
||||
event: MotionEvent,
|
||||
cfg: GestureConfig,
|
||||
w: Int,
|
||||
h: Int,
|
||||
bottomInsetPx: Int,
|
||||
) {
|
||||
reset()
|
||||
val minSide = min(w, h).toFloat()
|
||||
if (minSide <= 0f) {
|
||||
aborted = true
|
||||
return
|
||||
}
|
||||
active = true
|
||||
// Scale the configured base stroke length to the screen, matching the
|
||||
// reference add-on's `strokeSize * min(w, h) / 320`.
|
||||
strokeSize = cfg.strokeSize * minSide / REFERENCE_SHORT_SIDE
|
||||
edgeWidth = minSide / EDGE_DIVISOR
|
||||
// The visible content ends above the dynamic bottom toolbar.
|
||||
contentBottom = (h - bottomInsetPx).toFloat()
|
||||
lastX = event.getX(0)
|
||||
lastY = event.getY(0)
|
||||
startPosition = computeStartPosition(lastX, lastY, w)
|
||||
}
|
||||
|
||||
private fun onMove(event: MotionEvent, cfg: GestureConfig) {
|
||||
if (!active || aborted) return
|
||||
if (!setupFingers(event, cfg)) return
|
||||
if (arrows.size > MAX_ARROWS) return
|
||||
|
||||
val x = event.getX(0)
|
||||
val y = event.getY(0)
|
||||
val dx = x - lastX
|
||||
val dy = y - lastY
|
||||
val absX = abs(dx)
|
||||
val absY = abs(dy)
|
||||
if (absX < strokeSize && absY < strokeSize) return
|
||||
|
||||
lastX = x
|
||||
lastY = y
|
||||
val arrow = if (absX < absY) {
|
||||
if (dy < 0) 'U' else 'D'
|
||||
} else {
|
||||
if (dx < 0) 'L' else 'R'
|
||||
}
|
||||
// Collapse consecutive identical directions into a single stroke.
|
||||
if (arrow == lastArrow) return
|
||||
lastArrow = arrow
|
||||
arrows.add(arrow)
|
||||
onProgress?.invoke(currentKey())
|
||||
}
|
||||
|
||||
private fun onUp(cfg: GestureConfig): String? {
|
||||
val result = if (active && !aborted && arrows.isNotEmpty()) matchKey(cfg) else null
|
||||
reset()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun setupFingers(event: MotionEvent, cfg: GestureConfig): Boolean {
|
||||
val count = event.pointerCount
|
||||
if (count > cfg.maxFingers) {
|
||||
aborted = true
|
||||
return false
|
||||
}
|
||||
if (fingersNum < count) {
|
||||
fingersNum = count
|
||||
fingers = "$count:"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The current in-progress canonical key including the start-position and
|
||||
* finger prefixes, e.g. `R:2:D-L`. Used for live feedback, where the
|
||||
* consumer matches it against configured bindings to suggest completions.
|
||||
*/
|
||||
private fun currentKey(): String = startPosition + fingers + arrows.joinToString("-")
|
||||
|
||||
private fun matchKey(cfg: GestureConfig): String? {
|
||||
val input = fingers + arrows.joinToString("-")
|
||||
val withStart = startPosition + input
|
||||
return when {
|
||||
cfg.activeGestureKeys.contains(withStart) -> withStart
|
||||
cfg.activeGestureKeys.contains(input) -> input
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeStartPosition(x: Float, y: Float, w: Int): String {
|
||||
return when {
|
||||
x < edgeWidth -> "L:"
|
||||
x > w - edgeWidth -> "R:"
|
||||
y < edgeWidth -> "T:"
|
||||
y > contentBottom - edgeWidth -> "B:"
|
||||
x < w / 2f -> "W:"
|
||||
else -> "E:"
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
arrows.clear()
|
||||
startPosition = ""
|
||||
fingers = ""
|
||||
fingersNum = 1
|
||||
lastArrow = ' '
|
||||
aborted = false
|
||||
active = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Maximum number of direction strokes per gesture. */
|
||||
private const val MAX_ARROWS = 9
|
||||
|
||||
/** Reference short-side length the base stroke size is calibrated for. */
|
||||
private const val REFERENCE_SHORT_SIDE = 320f
|
||||
|
||||
/** Edge-zone width is `min(width, height) / EDGE_DIVISOR`. */
|
||||
private const val EDGE_DIVISOR = 10f
|
||||
}
|
||||
}
|
||||
+205
@@ -5572,6 +5572,81 @@ data class SandboxCaptureEntry (
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for native touch-gesture recognition.
|
||||
*
|
||||
* Pushed from Dart whenever the user's gesture settings change. Native
|
||||
* recognition is purely observational: it assembles a canonical stroke key
|
||||
* (start-position prefix + finger-count prefix + dash-joined directions, e.g.
|
||||
* `R:2:D-L`) and only emits when that key matches an entry in
|
||||
* [activeGestureKeys]. Strokes that do not match are ignored, so normal
|
||||
* scrolling, tapping and pinch-zoom are never affected.
|
||||
*
|
||||
* Generated class from Pigeon that represents data sent in messages.
|
||||
*/
|
||||
data class GestureConfig (
|
||||
val enabled: Boolean,
|
||||
/**
|
||||
* Base stroke length in logical pixels, scaled at runtime by
|
||||
* `min(viewWidth, viewHeight) / 320` to match the reference gesture add-on.
|
||||
*/
|
||||
val strokeSize: Long,
|
||||
/**
|
||||
* Milliseconds of inactivity after which an in-progress gesture is
|
||||
* discarded.
|
||||
*/
|
||||
val timeoutMs: Long,
|
||||
/** Maximum number of simultaneous pointers a gesture may use. */
|
||||
val maxFingers: Long,
|
||||
/**
|
||||
* Canonical keys that currently have an action bound, e.g. `D-R`,
|
||||
* `R:2:D-L`. Native only emits [GeckoGestureEvents.onGestureRecognized]
|
||||
* when an assembled stroke matches one of these.
|
||||
*/
|
||||
val activeGestureKeys: List<String>
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): GestureConfig {
|
||||
val enabled = pigeonVar_list[0] as Boolean
|
||||
val strokeSize = pigeonVar_list[1] as Long
|
||||
val timeoutMs = pigeonVar_list[2] as Long
|
||||
val maxFingers = pigeonVar_list[3] as Long
|
||||
val activeGestureKeys = pigeonVar_list[4] as List<String>
|
||||
return GestureConfig(enabled, strokeSize, timeoutMs, maxFingers, activeGestureKeys)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
enabled,
|
||||
strokeSize,
|
||||
timeoutMs,
|
||||
maxFingers,
|
||||
activeGestureKeys,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as GestureConfig
|
||||
return GeckoPigeonUtils.deepEquals(this.enabled, other.enabled) && GeckoPigeonUtils.deepEquals(this.strokeSize, other.strokeSize) && GeckoPigeonUtils.deepEquals(this.timeoutMs, other.timeoutMs) && GeckoPigeonUtils.deepEquals(this.maxFingers, other.maxFingers) && GeckoPigeonUtils.deepEquals(this.activeGestureKeys, other.activeGestureKeys)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.enabled)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.strokeSize)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.timeoutMs)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.maxFingers)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.activeGestureKeys)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -6190,6 +6265,11 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||
SandboxCaptureEntry.fromList(it)
|
||||
}
|
||||
}
|
||||
252.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
GestureConfig.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -6687,6 +6767,10 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(251)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is GestureConfig -> {
|
||||
stream.write(252)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
@@ -11829,3 +11913,124 @@ class SandboxCaptureHostEvents(private val binaryMessenger: BinaryMessenger, pri
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Dart → Kotlin. Pushes the current gesture-recognition configuration.
|
||||
*
|
||||
* Generated interface from Pigeon that represents a handler of messages from Flutter.
|
||||
*/
|
||||
interface GeckoGestureApi {
|
||||
fun setGestureConfig(config: GestureConfig)
|
||||
|
||||
companion object {
|
||||
/** The codec used by GeckoGestureApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `GeckoGestureApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: GeckoGestureApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureApi.setGestureConfig$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val configArg = args[0] as GestureConfig
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setGestureConfig(configArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
GeckoPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Kotlin → Dart. Emitted when an assembled touch stroke matches a configured
|
||||
* gesture key.
|
||||
*
|
||||
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
|
||||
*/
|
||||
class GeckoGestureEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||
companion object {
|
||||
/** The codec used by GeckoGestureEvents. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* [sequence] Event sequence number for ordering.
|
||||
* [gestureKey] Canonical key of the recognized gesture, e.g. `D-R`.
|
||||
*/
|
||||
fun onGestureRecognized(sequenceArg: Long, gestureKeyArg: String, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureRecognized$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(sequenceArg, gestureKeyArg)) {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Emitted while a stroke is being drawn, each time a new direction arrow is
|
||||
* appended. Drives the live feedback overlay.
|
||||
*
|
||||
* [sequence] Event sequence number for ordering.
|
||||
* [partialKey] Current partial canonical key including start/finger
|
||||
* prefixes, e.g. `R:D`.
|
||||
*/
|
||||
fun onGestureProgress(sequenceArg: Long, partialKeyArg: String, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureProgress$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(sequenceArg, partialKeyArg)) {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Emitted when an in-progress stroke ends (release, cancel or idle timeout)
|
||||
* so the live feedback overlay can be hidden.
|
||||
*
|
||||
* [sequence] Event sequence number for ordering.
|
||||
*/
|
||||
fun onGestureReset(sequenceArg: Long, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureReset$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(sequenceArg)) {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -12,6 +12,9 @@ import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||
import eu.weblibre.flutter_mozilla_components.feature.GestureRecognizer
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
@@ -34,6 +37,12 @@ import kotlin.math.abs
|
||||
*
|
||||
* Taps and vertical drags that originate in the inset still reach the engine
|
||||
* view, so links and vertical scrolling continue to work at the edges.
|
||||
*
|
||||
* This container is also the single observation point for configurable touch
|
||||
* gestures: every event is fed to a [GestureRecognizer] before the back-gesture
|
||||
* filter runs. Recognition is purely observational (events are never consumed
|
||||
* for gestures), so pages scroll and tap normally; a recognized multi-stroke
|
||||
* gesture is reported to Dart on touch-up via [GlobalComponents.gestureEvents].
|
||||
*/
|
||||
class BackGestureFilterFrameLayout(
|
||||
context: Context,
|
||||
@@ -44,7 +53,35 @@ class BackGestureFilterFrameLayout(
|
||||
private var startedInEdgeZone = false
|
||||
private var hasIntercepted = false
|
||||
|
||||
private val gestureRecognizer = GestureRecognizer()
|
||||
private val gestureTimeoutRunnable = Runnable { cancelGesture() }
|
||||
|
||||
/**
|
||||
* Whether the live feedback overlay is currently being shown for an
|
||||
* in-progress stroke. Tracked so a reset is reported to Dart only after a
|
||||
* progress event, keeping plain taps and scrolls off the platform channel.
|
||||
*/
|
||||
private var gestureFeedbackActive = false
|
||||
|
||||
init {
|
||||
// A new direction arrow was registered: restart the idle-timeout (the
|
||||
// reference add-on restarts only on a new arrow, not on every move) and
|
||||
// forward the partial stroke to the live feedback overlay.
|
||||
gestureRecognizer.onProgress = { partialKey ->
|
||||
val config = GlobalComponents.gestureConfig
|
||||
if (config != null && config.enabled) {
|
||||
removeCallbacks(gestureTimeoutRunnable)
|
||||
postDelayed(gestureTimeoutRunnable, config.timeoutMs)
|
||||
gestureFeedbackActive = true
|
||||
GlobalComponents.gestureEvents
|
||||
?.onGestureProgress(EventSequence.next(), partialKey) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
feedGestureRecognizer(ev)
|
||||
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downX = ev.x
|
||||
@@ -68,6 +105,9 @@ class BackGestureFilterFrameLayout(
|
||||
// already scrolled the page. Cancel APZ now and swallow the rest.
|
||||
dx > dy + 1 -> {
|
||||
hasIntercepted = true
|
||||
// The stream now belongs to the system back gesture;
|
||||
// discard any partial touch gesture so it can't fire.
|
||||
cancelGesture()
|
||||
// Log.d(TAG, "INTERCEPT dx=$dx dy=$dy")
|
||||
dispatchSyntheticCancel(ev)
|
||||
return true
|
||||
@@ -93,6 +133,69 @@ class BackGestureFilterFrameLayout(
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes every touch event for configurable gestures. Reads the latest
|
||||
* config pushed from Dart, drives the idle-timeout, and reports a matched
|
||||
* gesture key on touch-up. Runs on the UI thread (dispatchTouchEvent), so
|
||||
* the event sink can be invoked directly.
|
||||
*/
|
||||
private fun feedGestureRecognizer(ev: MotionEvent) {
|
||||
val config = GlobalComponents.gestureConfig
|
||||
gestureRecognizer.config = config
|
||||
|
||||
val key = gestureRecognizer.feed(
|
||||
ev,
|
||||
width,
|
||||
height,
|
||||
GlobalComponents.bottomViewportInsetPx,
|
||||
)
|
||||
|
||||
when (ev.actionMasked) {
|
||||
// Start the idle window on touch-down; subsequent restarts happen
|
||||
// only when the recognizer reports a new arrow (see onProgress).
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
// New touch: clear any gesture claim so pull-to-refresh is only
|
||||
// suppressed when this stroke actually matches a gesture.
|
||||
GlobalComponents.touchConsumedByGesture = false
|
||||
if (config != null && config.enabled) {
|
||||
removeCallbacks(gestureTimeoutRunnable)
|
||||
postDelayed(gestureTimeoutRunnable, config.timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
removeCallbacks(gestureTimeoutRunnable)
|
||||
emitGestureReset()
|
||||
}
|
||||
}
|
||||
|
||||
if (key != null) {
|
||||
// Mark this touch as gesture-handled before the event propagates to
|
||||
// the swipe-refresh layout's own ACTION_UP handling, so a redundant
|
||||
// pull-to-refresh reload is suppressed (see
|
||||
// GestureAwareSwipeRefreshFeature).
|
||||
GlobalComponents.touchConsumedByGesture = true
|
||||
GlobalComponents.gestureEvents
|
||||
?.onGestureRecognized(EventSequence.next(), key) { }
|
||||
}
|
||||
}
|
||||
|
||||
/** Discards any in-progress gesture and clears its feedback overlay. */
|
||||
private fun cancelGesture() {
|
||||
removeCallbacks(gestureTimeoutRunnable)
|
||||
gestureRecognizer.cancel()
|
||||
emitGestureReset()
|
||||
}
|
||||
|
||||
/** Tells Dart to hide the live feedback overlay, if one is showing. */
|
||||
private fun emitGestureReset() {
|
||||
if (!gestureFeedbackActive) return
|
||||
gestureFeedbackActive = false
|
||||
GlobalComponents.gestureEvents
|
||||
?.onGestureReset(EventSequence.next()) { }
|
||||
}
|
||||
|
||||
private fun dispatchSyntheticCancel(source: MotionEvent) {
|
||||
val cancel = MotionEvent.obtain(source).apply {
|
||||
action = MotionEvent.ACTION_CANCEL
|
||||
|
||||
Reference in New Issue
Block a user