From 5be38b76962f40948e10f978cc5648cf8a9057b7 Mon Sep 17 00:00:00 2001 From: Heikki Lampinen Date: Mon, 2 Nov 2026 22:56:25 +0200 Subject: [PATCH] Send only the user's dismissals of iOS sheets and modals to PHP SwiftUI calls a presentation's onDismiss for every close, also the one PHP asked for by setting `visible` to false. Both renderers sent each call to PHP as `@dismiss`, so PHP's own close came back as a dismissal. The event is asynchronous: when a handler closed a sheet and the next tap opened it again, the late dismissal closed the sheet that was just opened. Android sends only from onDismissRequest, so it has no such echo. The bottom sheet now marks the closes that present(false) starts and skips them in onDismiss. The binding can't tell the two apart: SwiftUI writes it for PHP's closes too. onAppear clears the mark, so a close that never reached onDismiss can't swallow the next swipe-down. The modal's full-screen cover is bound to a constant, so it can't be swiped away and only goes away when PHP sets `visible` to false. Its onDismiss is gone; the close button still sends `@dismiss`, as the header comment already said. Co-Authored-By: Claude Opus 5.5 --- .../ios/NativeUIBottomSheetRenderer.swift | 18 +++++++++++++++++- resources/ios/NativeUIModalRenderer.swift | 13 +++++-------- tests/BottomSheetRendererTest.php | 11 +++++++++++ tests/ModalRendererTest.php | 10 ++++++++++ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/resources/ios/NativeUIBottomSheetRenderer.swift b/resources/ios/NativeUIBottomSheetRenderer.swift index 0b135cb..a9f804e 100644 --- a/resources/ios/NativeUIBottomSheetRenderer.swift +++ b/resources/ios/NativeUIBottomSheetRenderer.swift @@ -12,6 +12,8 @@ struct NativeUIBottomSheetRenderer: View { @Environment(\.colorScheme) private var colorScheme @State private var isPresented: Bool = false + /// PHP set `visible` to false, and the sheet hasn't finished closing yet. + @State private var closedByPhp: Bool = false @State private var fitHeight: CGFloat? @State private var selectedDetent: PresentationDetent = .medium @@ -29,6 +31,14 @@ struct NativeUIBottomSheetRenderer: View { Color.clear.frame(width: 0, height: 0) .sheet(isPresented: $isPresented, onDismiss: { + // SwiftUI calls onDismiss for every close, also the one PHP + // asked for, and it writes the binding for both, so only + // present(false) can tell them apart. Report the user's + // dismissals only, as Android's onDismissRequest does. + if closedByPhp { + closedByPhp = false + return + } if onDismissCb != 0 { NativeUIBridge.sendSheetDismissEvent(onDismissCb, nodeId: node.id) } @@ -50,7 +60,12 @@ struct NativeUIBottomSheetRenderer: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(theme.surface) } - .onAppear { NativeUIKeyboardAccessoryState.shared.sheetDepth += 1 } + .onAppear { + NativeUIKeyboardAccessoryState.shared.sheetDepth += 1 + // A close that never reached onDismiss must not swallow + // the user's dismissal of this presentation. + closedByPhp = false + } .onDisappear { NativeUIKeyboardAccessoryState.shared.sheetDepth -= 1 } .modifier(DetentsModifier( detents: resolveDetents(detentsStr), @@ -70,6 +85,7 @@ struct NativeUIBottomSheetRenderer: View { private func present(_ visible: Bool) { if visible && !isPresented { selectedDetent = fitDetent } + if !visible && isPresented { closedByPhp = true } isPresented = visible } diff --git a/resources/ios/NativeUIModalRenderer.swift b/resources/ios/NativeUIModalRenderer.swift index bd83b2c..bd6fb0f 100644 --- a/resources/ios/NativeUIModalRenderer.swift +++ b/resources/ios/NativeUIModalRenderer.swift @@ -3,7 +3,7 @@ import SwiftUI /// Full-screen modal overlay. Visibility driven by the `visible` prop. /// /// Dismiss semantics: the `@dismiss` callback fires only from explicit user -/// actions (close button tap, system swipe-to-dismiss when dismissible). +/// actions (the close button tap; a full-screen cover has no swipe-to-dismiss). /// Programmatic `visible = false` from PHP does NOT fire dismiss — that /// would double-invoke the callback that just set the flag. /// @@ -30,13 +30,10 @@ struct NativeUIModalRenderer: View { Color.clear .frame(width: 0, height: 0) - .fullScreenCover(isPresented: .constant(visible), onDismiss: { - // Fired by system swipe-to-dismiss (iOS sheets) or when the - // parent flips `visible` false via its own action. Intentional: - // system-driven dismissals still need to notify PHP so the - // component's boolean stays in sync. - fireDismiss() - }) { + // No onDismiss: on a constant binding the cover only goes away + // because PHP set `visible` to false, so onDismiss could only + // send that close back to PHP as a dismissal. + .fullScreenCover(isPresented: .constant(visible)) { VStack(spacing: 0) { if dismissible { HStack { diff --git a/tests/BottomSheetRendererTest.php b/tests/BottomSheetRendererTest.php index 0b6c7e4..aa15a8e 100644 --- a/tests/BottomSheetRendererTest.php +++ b/tests/BottomSheetRendererTest.php @@ -45,3 +45,14 @@ ->toMatch('/\.onChange\(of: fitHeight\) \{ old, _ in\s*' .'if selectedDetent == Self\.fitDetent\(for: old\) \{ selectedDetent = fitDetent \}/s'); }); + +test('reports only the user\'s dismissals of an iOS sheet to PHP', function () { + $renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIBottomSheetRenderer.swift'); + + expect($renderer) + ->toBeString() + ->toMatch('/@State private var closedByPhp: Bool = false/') + ->toMatch('/private func present\(_ visible: Bool\) \{.*?if !visible && isPresented \{ closedByPhp = true \}\s*isPresented = visible/s') + ->toMatch('/\.sheet\(isPresented: \$isPresented, onDismiss: \{\s*(?:\/\/[^\n]*\n\s*)*if closedByPhp \{\s*closedByPhp = false\s*return\s*\}\s*if onDismissCb != 0 \{/') + ->toMatch('/\.onAppear \{\s*NativeUIKeyboardAccessoryState\.shared\.sheetDepth \+= 1\s*(?:\/\/[^\n]*\n\s*)*closedByPhp = false\s*\}/'); +}); diff --git a/tests/ModalRendererTest.php b/tests/ModalRendererTest.php index 2b59e99..9c7b5b7 100644 --- a/tests/ModalRendererTest.php +++ b/tests/ModalRendererTest.php @@ -26,3 +26,13 @@ ->toMatch('/WindowCompat\.getInsetsController\(dialogWindow, dialogView\)\.isAppearanceLightStatusBars =\s*' .'WindowCompat\.getInsetsController\(screenWindow, screenWindow\.decorView\)\.isAppearanceLightStatusBars/'); }); + +test('sends no dismissal to PHP when PHP closes the iOS modal', function () { + $renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIModalRenderer.swift'); + + expect($renderer) + ->toBeString() + ->toMatch('/\.fullScreenCover\(isPresented: \.constant\(visible\)\) \{/') + ->not->toMatch('/onDismiss: \{/') + ->toMatch('/Button\(action: fireDismiss\)/'); +});