Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion resources/ios/NativeUIBottomSheetRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
Expand All @@ -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),
Expand All @@ -70,6 +85,7 @@ struct NativeUIBottomSheetRenderer: View {

private func present(_ visible: Bool) {
if visible && !isPresented { selectedDetent = fitDetent }
if !visible && isPresented { closedByPhp = true }
isPresented = visible
}

Expand Down
13 changes: 5 additions & 8 deletions resources/ios/NativeUIModalRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions tests/BottomSheetRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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*\}/');
});
10 changes: 10 additions & 0 deletions tests/ModalRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\)/');
});