Skip to content

Send only the user's dismissals of iOS sheets and modals to PHP - #196

Open
Hesesses wants to merge 1 commit into
NativePHP:mainfrom
Hesesses:fix/sheet-dismiss-echo
Open

Hesesses wants to merge 1 commit into
NativePHP:mainfrom
Hesesses:fix/sheet-dismiss-echo

Conversation

@Hesesses

@Hesesses Hesesses commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

On iOS, a <bottom-sheet> and a <modal> send @dismiss to PHP when PHP itself closes them. SwiftUI calls onDismiss for every close of a presentation, also the one PHP asked for by setting visible to false, and both renderers send each call as @dismiss. Android sends only from onDismissRequest, so it has no such echo.

The event is asynchronous. If a handler closes a sheet and the next tap opens it again, the late dismissal arrives after the reopen and closes the sheet that was just opened. You see the sheet open, close and open again.

We found it in our app: a score sheet that saves and closes in one tap closed itself when you opened it for the next match. We had worked around it in nine screens with timers that drop any @dismiss arriving within 0.5s of opening.

Steps to reproduce

use Illuminate\View\View;
use Native\Mobile\Edge\NativeComponent;

class SheetEcho extends NativeComponent
{
    public bool $open = false;

    public int $saved = 0;

    public function openSheet(): void
    {
        $this->open = true;
    }

    public function save(): void
    {
        $this->saved++;
        $this->open = false;
    }

    public function closeSheet(): void
    {
        logger("closeSheet() after {$this->saved} saves");
        $this->open = false;
    }

    public function render(): View
    {
        return view('sheet-echo');
    }
}
<column class="w-full h-full p-6 gap-4">
    <button label="Open" @tap="openSheet" />

    <bottom-sheet :visible="$open" @dismiss="closeSheet" detents="fit">
        <column class="w-full p-6">
            <button label="Save" @tap="save" />
        </column>
    </bottom-sheet>
</column>
  1. Tap Open, then Save. closeSheet() is logged, although nobody dismissed the sheet.
  2. Tap Open, Save, and Open again quickly. The late @dismiss closes the sheet you just opened.

On Android, closeSheet() runs only for a swipe-down or back.

The fix

Bottom sheet. present(false) marks the closes that PHP starts, and onDismiss skips them. The binding can't tell the two kinds of close apart, because SwiftUI writes it for PHP's closes too. We checked this with a log line on the simulator: a binding-based version still reported PHP's close. The sheet content's onAppear clears the mark, so a close that never reached onDismiss can't swallow the next swipe-down.

Modal. The full-screen cover is bound to .constant(visible). It can't be swiped away, so it only goes away when PHP sets visible to false, and its onDismiss could only echo that close. The onDismiss is gone. The close button still sends @dismiss, which is what the header comment already describes.

Testing

  • tests/BottomSheetRendererTest.php and tests/ModalRendererTest.php each get a test, and both fail without the change. composer test (487 passed) and composer lint:test pass.
  • On an iPhone 17 Pro simulator (iOS 26.3), with a log line in onDismiss:
    • Closing from PHP was skipped, and nothing reached PHP.
    • A swipe-down was sent, PHP closed its state, and the sheet opened again normally afterwards.
  • The same change runs as a patch in our app, which let us remove our nine timers.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant