diff --git a/app/README.md b/app/README.md index d7ef3bb4f..1b68431b2 100644 --- a/app/README.md +++ b/app/README.md @@ -43,6 +43,9 @@ For being able to open the app from a custom scheme you need to register the sch `custom_url_scheme` value is stored in `strings.xml`. When the Android platform is added, `@capacitor/cli` adds the app's package name as default value, but can be replaced by editing the `strings.xml` file. +## Android Predictive Back +Android predictive back also requires `android:enableOnBackInvokedCallback="true"` on `` in your `AndroidManifest.xml` (on Android 14; default on Android 15+). + ## Example ```typescript @@ -72,9 +75,10 @@ const checkAppLaunchUrl = async () => { -| Prop | Type | Description | Default | Since | -| ------------------------------ | -------------------- | ------------------------------------------------------------------------------ | ------------------ | ----- | -| **`disableBackButtonHandler`** | boolean | Disable the plugin's default back button handling. Only available for Android. | false | 7.1.0 | +| Prop | Type | Description | Default | Since | +| ------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- | +| **`disableBackButtonHandler`** | boolean | Disable the plugin's default back button handling. Only available for Android. | false | 7.1.0 | +| **`enableEdgeGestureHandler`** | boolean | Enable the plugin's edge gesture handler at startup. When enabled, the plugin emits `edgeGesture` events for system edge swipes (iOS left/right screen-edge pans, Android predictive back). On Android, enabling this handler suppresses the default `backButton` handler for the duration that the edge gesture handler is active. The Android predictive-back integration requires API 34 (Android 14) or later; on earlier versions the configuration is accepted but no events will be emitted. Android predictive back also requires `android:enableOnBackInvokedCallback="true"` on `<application>` in your `AndroidManifest.xml` (on Android 14; default on Android 15+). | false | 9.0.0 | ### Examples @@ -84,7 +88,8 @@ In `capacitor.config.json`: { "plugins": { "App": { - "disableBackButtonHandler": true + "disableBackButtonHandler": true, + "enableEdgeGestureHandler": true } } } @@ -101,6 +106,7 @@ const config: CapacitorConfig = { plugins: { App: { disableBackButtonHandler: true, + enableEdgeGestureHandler: true, }, }, }; @@ -121,12 +127,14 @@ export default config; * [`minimizeApp()`](#minimizeapp) * [`getAppLanguage()`](#getapplanguage) * [`toggleBackButtonHandler(...)`](#togglebackbuttonhandler) +* [`toggleEdgeGestureHandler(...)`](#toggleedgegesturehandler) * [`addListener('appStateChange', ...)`](#addlistenerappstatechange-) * [`addListener('pause', ...)`](#addlistenerpause-) * [`addListener('resume', ...)`](#addlistenerresume-) * [`addListener('appUrlOpen', ...)`](#addlistenerappurlopen-) * [`addListener('appRestoredResult', ...)`](#addlistenerapprestoredresult-) * [`addListener('backButton', ...)`](#addlistenerbackbutton-) +* [`addListener('edgeGesture', ...)`](#addlisteneredgegesture-) * [`removeAllListeners()`](#removealllisteners) * [Interfaces](#interfaces) * [Type Aliases](#type-aliases) @@ -246,6 +254,36 @@ Only available for Android. -------------------- +### toggleEdgeGestureHandler(...) + +```typescript +toggleEdgeGestureHandler(options: ToggleEdgeGestureHandlerOptions) => Promise +``` + +Enables or disables the plugin's edge gesture handling at runtime. + +When enabled, the plugin installs platform edge-gesture recognizers +and begins emitting `edgeGesture` events. When disabled, the +recognizers are removed and no further events are emitted. + +On Android, enabling the edge gesture handler temporarily disables +the default `backButton` handler; disabling it restores the previous +back button handler state. The Android predictive-back integration +requires API 34 (Android 14) or later; on earlier versions the call +resolves but no events will be emitted. Android predictive back also +requires `android:enableOnBackInvokedCallback="true"` on +`<application>` in your `AndroidManifest.xml` (on Android 14; default +on Android 15+). + +| Param | Type | +| ------------- | ------------------------------------------------------------------------------------------- | +| **`options`** | ToggleEdgeGestureHandlerOptions | + +**Since:** 9.0.0 + +-------------------- + + ### addListener('appStateChange', ...) ```typescript @@ -403,6 +441,38 @@ If you want to close the app, call `App.exitApp()`. -------------------- +### addListener('edgeGesture', ...) + +```typescript +addListener(eventName: 'edgeGesture', listenerFunc: EdgeGestureListener) => Promise +``` + +Listen for system edge-swipe gestures. + +On iOS this fires for left- and right-edge screen pans tracked by +`UIScreenEdgePanGestureRecognizer`. On Android this fires for the +predictive back gesture (requires Android 14 / API 34 or later). + +The edge gesture handler must be active for events to fire; enable +it via the `enableEdgeGestureHandler` configuration option or at +runtime via `toggleEdgeGestureHandler({ enabled: true })`. + +Each gesture produces a sequence of events: a single `start`, zero +or more `progress`, and then either `commit` (the gesture completed) +or `cancel` (the gesture was abandoned). + +| Param | Type | +| ------------------ | ------------------------------------------------------------------- | +| **`eventName`** | 'edgeGesture' | +| **`listenerFunc`** | EdgeGestureListener | + +**Returns:** Promise<PluginListenerHandle> + +**Since:** 9.0.0 + +-------------------- + + ### removeAllListeners() ```typescript @@ -457,6 +527,13 @@ Remove all native listeners for this plugin | **`enabled`** | boolean | Indicates whether to enable or disable default back button handling. | 7.1.0 | +#### ToggleEdgeGestureHandlerOptions + +| Prop | Type | Description | Since | +| ------------- | -------------------- | ---------------------------------------------------------------- | ----- | +| **`enabled`** | boolean | Whether to enable or disable the plugin's edge gesture handling. | 9.0.0 | + + #### PluginListenerHandle | Prop | Type | @@ -491,6 +568,17 @@ Remove all native listeners for this plugin | **`canGoBack`** | boolean | Indicates whether the browser can go back in history. False when the history stack is on the first entry. | 1.0.0 | +#### EdgeGestureListenerEvent + +| Prop | Type | Description | Since | +| --------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | +| **`phase`** | 'start' \| 'progress' \| 'cancel' \| 'commit' | The current phase of the edge gesture. - `start`: the user has initiated an edge swipe. - `progress`: the user is moving their finger; emitted continuously during the gesture. - `commit`: the user released the gesture and the system accepted it (for example, a back navigation should occur). - `cancel`: the user released the gesture without committing it, or the system cancelled it. | 9.0.0 | +| **`progress`** | number | How far the gesture has progressed, normalized between `0` and `1`. On `start` this is the initial progress reported by the system. On `progress` it updates as the user drags. On `commit` and `cancel` it reports the last observed progress value. | 9.0.0 | +| **`swipeEdge`** | 'none' \| 'left' \| 'right' | Which screen edge the gesture originated from. On iOS this is `'left'` or `'right'` (left/right screen-edge pans are tracked). On Android this reflects the value reported by the predictive-back system and may also be `'none'` when the platform does not report a specific edge. | 9.0.0 | +| **`touchX`** | number | X coordinate of the touch that initiated or is driving the gesture. On iOS the value is in points relative to the WebView. On Android the value is provided by the platform's `BackEvent.getTouchX()`. | 9.0.0 | +| **`touchY`** | number | Y coordinate of the touch that initiated or is driving the gesture. On iOS the value is in points relative to the WebView. On Android the value is provided by the platform's `BackEvent.getTouchY()`. | 9.0.0 | + + ### Type Aliases @@ -513,4 +601,9 @@ Remove all native listeners for this plugin (event: BackButtonListenerEvent): void + +#### EdgeGestureListener + +(event: EdgeGestureListenerEvent): void + diff --git a/app/android/src/main/java/com/capacitorjs/plugins/app/AppPlugin.java b/app/android/src/main/java/com/capacitorjs/plugins/app/AppPlugin.java index f14286f26..18268e39b 100644 --- a/app/android/src/main/java/com/capacitorjs/plugins/app/AppPlugin.java +++ b/app/android/src/main/java/com/capacitorjs/plugins/app/AppPlugin.java @@ -1,10 +1,19 @@ package com.capacitorjs.plugins.app; +import static android.window.BackEvent.EDGE_LEFT; +import static android.window.BackEvent.EDGE_NONE; +import static android.window.BackEvent.EDGE_RIGHT; + import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.net.Uri; +import android.os.Build; +import android.window.BackEvent; +import android.window.OnBackAnimationCallback; +import android.window.OnBackInvokedDispatcher; import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatDelegate; import androidx.core.content.pm.PackageInfoCompat; import androidx.core.os.LocaleListCompat; @@ -21,33 +30,40 @@ public class AppPlugin extends Plugin { private static final String EVENT_BACK_BUTTON = "backButton"; + private static final String EVENT_BACK_GESTURE = "edgeGesture"; private static final String EVENT_URL_OPEN = "appUrlOpen"; private static final String EVENT_STATE_CHANGE = "appStateChange"; private static final String EVENT_RESTORED_RESULT = "appRestoredResult"; private static final String EVENT_PAUSE = "pause"; private static final String EVENT_RESUME = "resume"; private boolean hasPausedEver = false; + private boolean backButtonHandlerEnabled = false; + private boolean edgeGestureHandlerEnabled = false; private OnBackPressedCallback onBackPressedCallback; + private OnBackAnimationCallback onBackAnimationCallback; + + private String activeEdge = null; + private Float lastEdgeProgress = null; + private Float lastEdgeTouchX = null; + private Float lastEdgeTouchY = null; public void load() { - boolean disableBackButtonHandler = getConfig().getBoolean("disableBackButtonHandler", false); - - bridge - .getApp() - .setStatusChangeListener((isActive) -> { - Logger.debug(getLogTag(), "Firing change: " + isActive); - JSObject data = new JSObject(); - data.put("isActive", isActive); - notifyListeners(EVENT_STATE_CHANGE, data, false); - }); - bridge - .getApp() - .setAppRestoredListener((result) -> { - Logger.debug(getLogTag(), "Firing restored result"); - notifyListeners(EVENT_RESTORED_RESULT, result.getWrappedResult(), true); - }); - this.onBackPressedCallback = new OnBackPressedCallback(!disableBackButtonHandler) { + this.backButtonHandlerEnabled = !getConfig().getBoolean("disableBackButtonHandler", false); + this.edgeGestureHandlerEnabled = getConfig().getBoolean("enableEdgeGestureHandler", false); + + bridge.getApp().setStatusChangeListener((isActive) -> { + Logger.debug(getLogTag(), "Firing change: " + isActive); + JSObject data = new JSObject(); + data.put("isActive", isActive); + notifyListeners(EVENT_STATE_CHANGE, data, false); + }); + bridge.getApp().setAppRestoredListener((result) -> { + Logger.debug(getLogTag(), "Firing restored result"); + notifyListeners(EVENT_RESTORED_RESULT, result.getWrappedResult(), true); + }); + + this.onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { if (!hasListeners(EVENT_BACK_BUTTON)) { @@ -64,6 +80,12 @@ public void handleOnBackPressed() { }; getActivity().getOnBackPressedDispatcher().addCallback(getActivity(), this.onBackPressedCallback); + + if (this.edgeGestureHandlerEnabled) { + this.setupBackGestureHandlers(); + } + + applyBackButtonHandlerState(); } @PluginMethod @@ -116,6 +138,15 @@ public void minimizeApp(PluginCall call) { call.resolve(); } + @PluginMethod + public void getAppLanguage(PluginCall call) { + JSObject ret = new JSObject(); + LocaleListCompat appLocales = AppCompatDelegate.getApplicationLocales(); + Locale appLocale = !appLocales.isEmpty() ? appLocales.get(0) : null; + ret.put("value", appLocale != null ? appLocale.getLanguage() : Locale.getDefault().getLanguage()); + call.resolve(ret); + } + @PluginMethod public void toggleBackButtonHandler(PluginCall call) { if (this.onBackPressedCallback == null) { @@ -123,19 +154,35 @@ public void toggleBackButtonHandler(PluginCall call) { return; } - Boolean enabled = call.getBoolean("enabled"); + backButtonHandlerEnabled = call.getBoolean("enabled", false); - this.onBackPressedCallback.setEnabled(enabled); + applyBackButtonHandlerState(); call.resolve(); } @PluginMethod - public void getAppLanguage(PluginCall call) { - JSObject ret = new JSObject(); - LocaleListCompat appLocales = AppCompatDelegate.getApplicationLocales(); - Locale appLocale = !appLocales.isEmpty() ? appLocales.get(0) : null; - ret.put("value", appLocale != null ? appLocale.getLanguage() : Locale.getDefault().getLanguage()); - call.resolve(ret); + public void toggleEdgeGestureHandler(PluginCall call) { + if (getActivity() == null) { + call.reject("activity is null"); + return; + } + + edgeGestureHandlerEnabled = call.getBoolean("enabled", false); + applyBackButtonHandlerState(); + + if (edgeGestureHandlerEnabled) { + setupBackGestureHandlers(); + } else { + teardownBackGestureHandlers(); + } + + call.resolve(); + } + + private void applyBackButtonHandlerState() { + if (this.onBackPressedCallback != null) { + this.onBackPressedCallback.setEnabled(backButtonHandlerEnabled && !edgeGestureHandlerEnabled); + } } /** @@ -176,10 +223,120 @@ protected void handleOnResume() { @Override protected void handleOnDestroy() { unsetAppListeners(); + teardownBackGestureHandlers(); } private void unsetAppListeners() { bridge.getApp().setStatusChangeListener(null); bridge.getApp().setAppRestoredListener(null); } + + private void setupBackGestureHandlers() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (this.onBackAnimationCallback != null) { + return; + } + + this.onBackAnimationCallback = new OnBackAnimationCallback() { + @Override + public void onBackInvoked() { + if (hasListeners(EVENT_BACK_GESTURE)) { + JSObject data = new JSObject(); + data.put("phase", "commit"); + data.put("progress", lastEdgeProgress); + data.put("touchX", lastEdgeTouchX); + data.put("touchY", lastEdgeTouchY); + data.put("swipeEdge", activeEdge); + + notifyListeners(EVENT_BACK_GESTURE, data, true); + + lastEdgeProgress = null; + lastEdgeTouchX = null; + lastEdgeTouchY = null; + activeEdge = null; + } + } + + @Override + public void onBackStarted(@NonNull BackEvent backEvent) { + if (hasListeners(EVENT_BACK_GESTURE)) { + JSObject data = new JSObject(); + data.put("phase", "start"); + data.put("progress", backEvent.getProgress()); + data.put("touchX", backEvent.getTouchX()); + data.put("touchY", backEvent.getTouchY()); + data.put("swipeEdge", getSwipeEdge(backEvent.getSwipeEdge())); + + notifyListeners(EVENT_BACK_GESTURE, data, true); + + lastEdgeProgress = backEvent.getProgress(); + lastEdgeTouchX = backEvent.getTouchX(); + lastEdgeTouchY = backEvent.getTouchY(); + activeEdge = getSwipeEdge(backEvent.getSwipeEdge()); + } + } + + @Override + public void onBackProgressed(@NonNull BackEvent backEvent) { + if (hasListeners(EVENT_BACK_GESTURE)) { + JSObject data = new JSObject(); + data.put("phase", "progress"); + data.put("progress", backEvent.getProgress()); + data.put("touchX", backEvent.getTouchX()); + data.put("touchY", backEvent.getTouchY()); + data.put("swipeEdge", getSwipeEdge(backEvent.getSwipeEdge())); + + notifyListeners(EVENT_BACK_GESTURE, data, true); + + lastEdgeProgress = backEvent.getProgress(); + lastEdgeTouchX = backEvent.getTouchX(); + lastEdgeTouchY = backEvent.getTouchY(); + activeEdge = getSwipeEdge(backEvent.getSwipeEdge()); + } + } + + @Override + public void onBackCancelled() { + if (hasListeners(EVENT_BACK_GESTURE)) { + JSObject data = new JSObject(); + data.put("phase", "cancel"); + data.put("progress", lastEdgeProgress); + data.put("touchX", lastEdgeTouchX); + data.put("touchY", lastEdgeTouchY); + data.put("swipeEdge", activeEdge); + + notifyListeners(EVENT_BACK_GESTURE, data, true); + + lastEdgeProgress = null; + lastEdgeTouchX = null; + lastEdgeTouchY = null; + activeEdge = null; + } + } + }; + + getActivity() + .getOnBackInvokedDispatcher() + .registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, this.onBackAnimationCallback); + } + } + + private void teardownBackGestureHandlers() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (this.onBackAnimationCallback == null) { + return; + } + + getActivity().getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(this.onBackAnimationCallback); + this.onBackAnimationCallback = null; + } + } + + private String getSwipeEdge(int edge) { + return switch (edge) { + case EDGE_LEFT -> "left"; + case EDGE_RIGHT -> "right"; + default -> "none"; + }; + } } diff --git a/app/ios/Sources/AppPlugin/AppPlugin.swift b/app/ios/Sources/AppPlugin/AppPlugin.swift index c93b84a6d..ddf6faf6a 100644 --- a/app/ios/Sources/AppPlugin/AppPlugin.swift +++ b/app/ios/Sources/AppPlugin/AppPlugin.swift @@ -12,11 +12,20 @@ public class AppPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "getLaunchUrl", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getState", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "minimizeApp", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "toggleBackButtonHandler", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "toggleBackButtonHandler", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "toggleEdgeGestureHandler", returnType: CAPPluginReturnPromise) ] private var observers: [NSObjectProtocol] = [] + private var leftEdgePanRecognizer: UIScreenEdgePanGestureRecognizer? + private var rightEdgePanRecognizer: UIScreenEdgePanGestureRecognizer? override public func load() { + if getConfig().getBoolean("enableEdgeGestureHandler", false) { + DispatchQueue.main.async { [weak self] in + self?.loadGestureRecognizers() + } + } + NotificationCenter.default.addObserver(self, selector: #selector(self.handleUrlOpened(notification:)), name: Notification.Name.capacitorOpenURL, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.handleUniversalLink(notification:)), name: Notification.Name.capacitorOpenUniversalLink, object: nil) observers.append(NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: OperationQueue.main) { [weak self] (_) in @@ -125,4 +134,104 @@ public class AppPlugin: CAPPlugin, CAPBridgedPlugin { @objc func toggleBackButtonHandler(_ call: CAPPluginCall) { call.unimplemented() } + + @objc func toggleEdgeGestureHandler(_ call: CAPPluginCall) { + DispatchQueue.main.async { + if call.getBool("enabled", false) { + self.loadGestureRecognizers() + } else { + self.destroyGestureRecognizers() + } + + call.resolve() + } + } + + @objc func handleEdgePan(_ recognizer: UIScreenEdgePanGestureRecognizer) { + guard let view = bridge?.webView else { return } + + let translation = recognizer.translation(in: view) + let touch = recognizer.location(in: view) + let viewWidth = view.bounds.width + let viewHeight = view.bounds.height + + var data: [String: Any] = [:] + + data["touchX"] = touch.x + data["touchY"] = touch.y + + switch recognizer.edges { + case .left: + let progress = translation.x / viewWidth + data["swipeEdge"] = "left" + data["progress"] = max(0, min(1, progress)) + case .right: + let progress = -translation.x / viewWidth + data["swipeEdge"] = "right" + data["progress"] = max(0, min(1, progress)) + default: + break + } + + switch recognizer.state { + case .began: + data["phase"] = "start" + case .changed: + data["phase"] = "progress" + case .ended: + data["phase"] = "commit" + case .cancelled: + data["phase"] = "cancel" + case .failed: + data["phase"] = "cancel" + case .possible: + break + @unknown default: + break + } + + // dont notify if there is no phase + guard data["phase"] != nil else { return } + + if hasListeners("edgeGesture") { + notifyListeners("edgeGesture", data: data) + } + } + + private func loadGestureRecognizers() { + guard self.leftEdgePanRecognizer == nil, self.rightEdgePanRecognizer == nil else { return } + guard let window = bridge?.viewController?.view.window else { return } + + let leftEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(self.handleEdgePan(_:))) + leftEdgePanRecognizer.delegate = self + leftEdgePanRecognizer.edges = .left + + let rightEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(self.handleEdgePan(_:))) + rightEdgePanRecognizer.delegate = self + rightEdgePanRecognizer.edges = .right + + window.addGestureRecognizer(leftEdgePanRecognizer) + window.addGestureRecognizer(rightEdgePanRecognizer) + + self.leftEdgePanRecognizer = leftEdgePanRecognizer + self.rightEdgePanRecognizer = rightEdgePanRecognizer + } + + private func destroyGestureRecognizers() { + guard let leftEdgePanRecognizer = self.leftEdgePanRecognizer, + let rightEdgePanRecognizer = self.rightEdgePanRecognizer + else { return } + + leftEdgePanRecognizer.view?.removeGestureRecognizer(leftEdgePanRecognizer) + rightEdgePanRecognizer.view?.removeGestureRecognizer(rightEdgePanRecognizer) + + self.leftEdgePanRecognizer = nil + self.rightEdgePanRecognizer = nil + } +} + +extension AppPlugin: UIGestureRecognizerDelegate { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return gestureRecognizer === leftEdgePanRecognizer || gestureRecognizer === rightEdgePanRecognizer + } } diff --git a/app/src/definitions.ts b/app/src/definitions.ts index 1029be2ec..2ca96536e 100644 --- a/app/src/definitions.ts +++ b/app/src/definitions.ts @@ -15,6 +15,27 @@ declare module '@capacitor/cli' { * @example true */ disableBackButtonHandler?: boolean; + + /** + * Enable the plugin's edge gesture handler at startup. + * + * When enabled, the plugin emits `edgeGesture` events for system edge + * swipes (iOS left/right screen-edge pans, Android predictive back). + * + * On Android, enabling this handler suppresses the default + * `backButton` handler for the duration that the edge gesture handler + * is active. The Android predictive-back integration requires API 34 + * (Android 14) or later; on earlier versions the configuration is + * accepted but no events will be emitted. Android predictive back + * also requires `android:enableOnBackInvokedCallback="true"` on + * `` in your `AndroidManifest.xml` (on Android 14; + * default on Android 15+). + * + * @since 9.0.0 + * @default false + * @example true + */ + enableEdgeGestureHandler?: boolean; }; } } @@ -144,6 +165,66 @@ export interface BackButtonListenerEvent { canGoBack: boolean; } +export interface EdgeGestureListenerEvent { + /** + * The current phase of the edge gesture. + * + * - `start`: the user has initiated an edge swipe. + * - `progress`: the user is moving their finger; emitted continuously + * during the gesture. + * - `commit`: the user released the gesture and the system accepted it + * (for example, a back navigation should occur). + * - `cancel`: the user released the gesture without committing it, or + * the system cancelled it. + * + * @since 9.0.0 + */ + phase: 'start' | 'progress' | 'cancel' | 'commit'; + + /** + * How far the gesture has progressed, normalized between `0` and `1`. + * + * On `start` this is the initial progress reported by the system. On + * `progress` it updates as the user drags. On `commit` and `cancel` + * it reports the last observed progress value. + * + * @since 9.0.0 + */ + progress?: number; + + /** + * Which screen edge the gesture originated from. + * + * On iOS this is `'left'` or `'right'` (left/right screen-edge pans + * are tracked). On Android this reflects the value reported by the + * predictive-back system and may also be `'none'` when the platform + * does not report a specific edge. + * + * @since 9.0.0 + */ + swipeEdge?: 'left' | 'right' | 'none'; + + /** + * X coordinate of the touch that initiated or is driving the gesture. + * + * On iOS the value is in points relative to the WebView. On Android the + * value is provided by the platform's `BackEvent.getTouchX()`. + * + * @since 9.0.0 + */ + touchX?: number; + + /** + * Y coordinate of the touch that initiated or is driving the gesture. + * + * On iOS the value is in points relative to the WebView. On Android the + * value is provided by the platform's `BackEvent.getTouchY()`. + * + * @since 9.0.0 + */ + touchY?: number; +} + export interface ToggleBackButtonHandlerOptions { /** * Indicates whether to enable or disable default back button handling. @@ -153,10 +234,20 @@ export interface ToggleBackButtonHandlerOptions { enabled: boolean; } +export interface ToggleEdgeGestureHandlerOptions { + /** + * Whether to enable or disable the plugin's edge gesture handling. + * + * @since 9.0.0 + */ + enabled: boolean; +} + export type StateChangeListener = (state: AppState) => void; export type URLOpenListener = (event: URLOpenListenerEvent) => void; export type RestoredListener = (event: RestoredListenerEvent) => void; export type BackButtonListener = (event: BackButtonListenerEvent) => void; +export type EdgeGestureListener = (event: EdgeGestureListenerEvent) => void; export interface AppLanguageCode { /** @@ -224,6 +315,26 @@ export interface AppPlugin { */ toggleBackButtonHandler(options: ToggleBackButtonHandlerOptions): Promise; + /** + * Enables or disables the plugin's edge gesture handling at runtime. + * + * When enabled, the plugin installs platform edge-gesture recognizers + * and begins emitting `edgeGesture` events. When disabled, the + * recognizers are removed and no further events are emitted. + * + * On Android, enabling the edge gesture handler temporarily disables + * the default `backButton` handler; disabling it restores the previous + * back button handler state. The Android predictive-back integration + * requires API 34 (Android 14) or later; on earlier versions the call + * resolves but no events will be emitted. Android predictive back also + * requires `android:enableOnBackInvokedCallback="true"` on + * `` in your `AndroidManifest.xml` (on Android 14; default + * on Android 15+). + * + * @since 9.0.0 + */ + toggleEdgeGestureHandler(options: ToggleEdgeGestureHandlerOptions): Promise; + /** * Listen for changes in the app or the activity states. * @@ -303,6 +414,25 @@ export interface AppPlugin { */ addListener(eventName: 'backButton', listenerFunc: BackButtonListener): Promise; + /** + * Listen for system edge-swipe gestures. + * + * On iOS this fires for left- and right-edge screen pans tracked by + * `UIScreenEdgePanGestureRecognizer`. On Android this fires for the + * predictive back gesture (requires Android 14 / API 34 or later). + * + * The edge gesture handler must be active for events to fire; enable + * it via the `enableEdgeGestureHandler` configuration option or at + * runtime via `toggleEdgeGestureHandler({ enabled: true })`. + * + * Each gesture produces a sequence of events: a single `start`, zero + * or more `progress`, and then either `commit` (the gesture completed) + * or `cancel` (the gesture was abandoned). + * + * @since 9.0.0 + */ + addListener(eventName: 'edgeGesture', listenerFunc: EdgeGestureListener): Promise; + /** * Remove all native listeners for this plugin * diff --git a/app/src/web.ts b/app/src/web.ts index be23cc767..b749bee16 100644 --- a/app/src/web.ts +++ b/app/src/web.ts @@ -32,6 +32,10 @@ export class AppWeb extends WebPlugin implements AppPlugin { throw this.unimplemented('Not implemented on web.'); } + async toggleEdgeGestureHandler(): Promise { + throw this.unimplemented('Not implemented on web.'); + } + private handleVisibilityChange = () => { const data = { isActive: document.hidden !== true,