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\)/'); +});