Skip to content

[web] Enable address bar collapse on mobile by switching touch input - #184281

Closed
koji-1009 wants to merge 4 commits into
flutter:masterfrom
koji-1009:feat/web_ios_address_bar_callapse
Closed

koji-1009 wants to merge 4 commits into
flutter:masterfrom
koji-1009:feat/web_ios_address_bar_callapse

Conversation

@koji-1009

@koji-1009 koji-1009 commented Mar 28, 2026 •

Copy link
Copy Markdown
Contributor

Note

This is a proof-of-concept for discussion, not a merge-ready PR.
Looking for feedback on the approach before investing further.
An opt-in flag, and any change to the existing input path (PointerBinding), are out of scope here.

Summary

Addresses #69529

On mobile the browser address bar never collapses because Flutter web sets touch-action: none on body, so the browser never sees a scroll. Four things, on Android and iOS, and only when Flutter owns the whole page:

  1. touch-action: pan-y on body, so the browser reads a vertical drag as a scroll.
  2. A spacer under <html>, giving the document real overflow — a scroll for the browser to consume and spend on its address bar.
  3. Everything that scroll would otherwise do to the app, held off: it must not move, resize, pull to refresh or carry momentum. Only the bar reacts.
  4. The touch input stream rebuilt from Touch Events and pointerrawupdate, because pan-y makes the browser cancel the touch Pointer Events mid-drag.

iOS Safari

ios.mov

Android Chrome (Secure context)

android.mp4

Design decisions

  1. touch-action: pan-y on mobile.
    • The address bar doesn't collapse without it, and everything else here follows from the browser claiming the drag and ending the touch pointer stream.
  2. Touch input through Touch Events.
    • Not a preference between the two APIs: under pan-y the touch Pointer Event stream ends mid-drag, so it cannot carry touch input at all.
    • Touch Events cost pointerType and tilt, and leave multi-touch movement on the throttled touchmove.

Details

Touch input

Touch-type Pointer Events are withheld from PointerBinding and Touch Events are translated into PointerData in their place. The withholding happens in the bubble phase on the view root via stopImmediatePropagation — the controller is constructed before PointerBinding, so its listener runs first. It is deliberately not a capture listener on the window: platform views and native text fields are descendants of the view root and see the events before anything is stopped.

pointerrawupdate (MDN, Pointer Events Level 3) is the more precise movement source, since the browser throttles touchmove while it is scrolling. Safari doesn't implement it and Chrome withholds it outside a secure context, so touchmove is the fallback.

Two things follow from the spec. It never fires for the changes that fire pointerdown/pointerup, so the contact's lifetime comes from Touch Events. And it carries no id that can be matched to a Touch contact, so it is used only while a single finger is down; with more than one, the touchmoves, which do carry ids, drive the movement.

scroll-snap

Android uses y proximity with a calc(100lvh + 1px) spacer: Chrome won't collapse the bar under mandatory, and a longer scroll range would keep scrolling the page after the browser chrome is already gone.

On iOS the snapping is not about the bar but about the scroll range. y mandatory parks the page on a snap target in the middle of a 10000px spacer, thousands of pixels from either end, which keeps pull-to-refresh and rubber-banding out of reach and stops Safari's momentum scrolling from carrying on past the gesture. Under proximity both come back.

Test app (lib/main.dart)
import 'package:material_ui/material_ui.dart';

void main() {
  runApp(const WebScrollTestApp());
}

class WebScrollTestApp extends StatelessWidget {
  const WebScrollTestApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Address Bar Controller Test',
      home: const TestMenu(),
    );
  }
}

class TestMenu extends StatelessWidget {
  const TestMenu({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tests')),
      body: ListView(
        padding: const .all(16),
        children: [
          _TestTile(
            title: 'Basic Vertical ListView',
            subtitle: 'Address bar should collapse when scrolling down',
            destination: const BasicVerticalListTest(),
          ),
          _TestTile(
            title: 'Nested Scrollables',
            subtitle: 'Horizontal PageView inside a vertical ListView',
            destination: const NestedScrollTest(),
          ),
          _TestTile(
            title: 'Interactive Widgets',
            subtitle: 'Buttons, sliders, text fields inside a scroll view',
            destination: const InteractiveWidgetsTest(),
          ),
          _TestTile(
            title: 'Tap-Only Page (No Scroll)',
            subtitle: 'Non-scrollable page with tappable buttons',
            destination: const TapOnlyTest(),
          ),
        ],
      ),
    );
  }
}

class _TestTile extends StatelessWidget {
  const _TestTile({
    required this.title,
    required this.subtitle,
    required this.destination,
  });

  final String title;
  final String subtitle;
  final Widget destination;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(title),
        subtitle: Text(subtitle),
        trailing: const Icon(Icons.arrow_forward_ios, size: 16),
        onTap: () {
          Navigator.of(context)
              .push(MaterialPageRoute<void>(builder: (_) => destination));
        },
      ),
    );
  }
}

class BasicVerticalListTest extends StatelessWidget {
  const BasicVerticalListTest({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Basic Vertical List')),
      body: ListView.builder(
        itemCount: 100,
        itemBuilder: (context, index) => ListTile(
          leading: CircleAvatar(child: Text('${index + 1}')),
          title: Text('Item ${index + 1}'),
          subtitle: Text('Scroll to collapse the address bar'),
          tileColor: index.isEven ? null : Colors.grey.shade50,
        ),
      ),
    );
  }
}

class NestedScrollTest extends StatelessWidget {
  const NestedScrollTest({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Nested Scrollables')),
      body: ListView(
        children: [
          const Padding(
            padding: .all(16),
            child: Text(
              'Vertical scroll should collapse address bar.\n'
              'Horizontal swipe should switch pages without interfering.',
              style: TextStyle(fontSize: 14, color: Colors.grey),
            ),
          ),
          // Horizontal PageView
          SizedBox(
            height: 200,
            child: PageView.builder(
              itemCount: 10,
              itemBuilder: (context, index) => Card(
                margin: const .symmetric(horizontal: 16, vertical: 8),
                color:
                    Colors.primaries[index % Colors.primaries.length].shade200,
                child: Center(
                  child: Text(
                    'Page ${index + 1}',
                    style: const TextStyle(
                      fontSize: 24,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ),
            ),
          ),
          // More vertical content
          for (int i = 0; i < 50; i++)
            ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.teal.shade100,
                child: Text('${i + 1}'),
              ),
              title: Text('List item ${i + 1}'),
            ),
        ],
      ),
    );
  }
}

class InteractiveWidgetsTest extends StatefulWidget {
  const InteractiveWidgetsTest({super.key});

  @override
  State<InteractiveWidgetsTest> createState() => _InteractiveWidgetsTestState();
}

class _InteractiveWidgetsTestState extends State<InteractiveWidgetsTest> {
  double _sliderValue = 0.5;
  bool _switchValue = false;
  int _tapCount = 0;
  final _textController = TextEditingController();

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Interactive Widgets')),
      body: ListView(
        padding: const .all(16),
        children: [
          const Text(
            'All widgets below should remain fully interactive '
            'even with the address bar controller active.',
            style: TextStyle(color: Colors.grey),
          ),
          const SizedBox(height: 16),

          // Tap counter
          Card(
            child: Padding(
              padding: const .all(16),
              child: Column(
                children: [
                  Text(
                    'Tap count: $_tapCount',
                    style: const TextStyle(fontSize: 18),
                  ),
                  const SizedBox(height: 8),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      ElevatedButton(
                        onPressed: () => setState(() => _tapCount++),
                        child: const Text('Tap me'),
                      ),
                      const SizedBox(width: 12),
                      OutlinedButton(
                        onPressed: () => setState(() => _tapCount = 0),
                        child: const Text('Reset'),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 12),

          // Slider
          Card(
            child: Padding(
              padding: const .all(16),
              child: Column(
                children: [
                  Text('Slider: ${_sliderValue.toStringAsFixed(2)}'),
                  Slider(
                    value: _sliderValue,
                    onChanged: (v) => setState(() => _sliderValue = v),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 12),

          // Switch
          Card(
            child: SwitchListTile(
              title: const Text('Toggle switch'),
              value: _switchValue,
              onChanged: (v) => setState(() => _switchValue = v),
            ),
          ),
          const SizedBox(height: 12),

          // TextField
          Card(
            child: Padding(
              padding: const .all(16),
              child: TextField(
                controller: _textController,
                decoration: const InputDecoration(
                  labelText: 'Type something',
                  border: OutlineInputBorder(),
                ),
              ),
            ),
          ),
          const SizedBox(height: 12),

          // GestureDetector with long press
          Card(
            child: GestureDetector(
              onLongPress: () {
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text('Long press detected!')),
                );
              },
              child: Container(
                padding: const .all(24),
                alignment: Alignment.center,
                child: const Text('Long press me'),
              ),
            ),
          ),

          // Spacer items to make scrollable
          for (int i = 0; i < 30; i++)
            ListTile(
              title: Text('Filler item ${i + 1}'),
              tileColor: i.isEven ? Colors.grey.shade50 : null,
            ),
        ],
      ),
    );
  }
}

class TapOnlyTest extends StatefulWidget {
  const TapOnlyTest({super.key});

  @override
  State<TapOnlyTest> createState() => _TapOnlyTestState();
}

class _TapOnlyTestState extends State<TapOnlyTest> {
  Color _color = Colors.blue;
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tap Only (No Scroll)')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'This page has no scrollable content.\n'
              'Taps, drags, and other gestures should work normally.',
              textAlign: TextAlign.center,
              style: TextStyle(color: Colors.grey),
            ),
            const SizedBox(height: 32),
            GestureDetector(
              onTap: () {
                setState(() {
                  _counter++;
                  _color = Colors.primaries[_counter % Colors.primaries.length];
                });
              },
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 300),
                width: 150,
                height: 150,
                decoration: BoxDecoration(
                  color: _color,
                  borderRadius: BorderRadius.circular(24),
                ),
                alignment: Alignment.center,
                child: Text(
                  '$_counter',
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 48,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
            const SizedBox(height: 24),
            const Text('Tap the box to change color'),
          ],
        ),
      ),
    );
  }
} 

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. platform-web Web applications specifically labels Mar 28, 2026
@koji-1009
koji-1009 force-pushed the feat/web_ios_address_bar_callapse branch 2 times, most recently from 0f5cf06 to 4891dc1 Compare March 30, 2026 14:35
@koji-1009 koji-1009 changed the title [web][ios] Enable address bar collapse by switching touch input [web] Enable address bar collapse on mobile by switching touch input Mar 30, 2026
@koji-1009

Copy link
Copy Markdown
Contributor Author

Yes, I've updated this PR to support Android as well.

@allanwolski

Copy link
Copy Markdown

Thanks @koji-1009, waiting for it.

@koji-1009
koji-1009 marked this pull request as ready for review June 10, 2026 21:54
@koji-1009
koji-1009 marked this pull request as draft June 10, 2026 21:55

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces AddressBarController to enable mobile browser address bar collapse for Flutter web apps by making the body scrollable and handling touch inputs via Touch Events. It also updates FullPageDimensionsProvider and EngineFlutterView to correctly track viewport changes on mobile. Feedback on these changes includes reusing the existing PointerDataConverter to prevent pointer ID collisions, using CSS calc() for the spacer height to support device rotation, replacing invalid for-in loops over DomTouchList with standard for loops to fix compilation errors, and explicitly specifying the touch device kind during pointer data conversion.

@koji-1009
koji-1009 force-pushed the feat/web_ios_address_bar_callapse branch from fc27ace to ceed65d Compare June 16, 2026 00:31
@github-actions github-actions Bot added the team-web Owned by Web platform team label Jun 16, 2026
@Piinks

Piinks commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Hey @koji-1009! This has not entered the review queue yet since it is a draft, are you still planning to move forward with this change? :)

@koji-1009

Copy link
Copy Markdown
Contributor Author

@Piinks
Hi! Since I've already created two open PRs, I can't change this from a draft.

#180292
#184549

@Piinks

Piinks commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Ah ok! Thanks for letting me know!

@koji-1009
koji-1009 force-pushed the feat/web_ios_address_bar_callapse branch from ceed65d to a3983e9 Compare August 30, 2026 14:24
@koji-1009
koji-1009 force-pushed the feat/web_ios_address_bar_callapse branch from a3983e9 to 38c6852 Compare August 31, 2026 22:30
@koji-1009
koji-1009 marked this pull request as ready for review August 31, 2026 23:24

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces AddressBarController to enable mobile browser address bar collapse for Flutter web apps on Android and iOS by setting up scroll machinery and translating touch events into pointer data. Feedback on these changes suggests restoring modified global styles on <body> and <html> during dispose, replacing unsafe null assertions on domWindow.innerHeight with null-coalescing fallbacks, using Timer.run instead of Timer(Duration.zero, ...), and refactoring Map.forEach to a for-in loop to align with Effective Dart guidelines.

@flutter-zl flutter-zl added the CICD Run CI/CD label Sep 23, 2026
@flutter-dashboard

Copy link
Copy Markdown

This pull request is not mergeable in its current state, likely because of a merge conflict. Pre-submit CI jobs were not triggered. Pushing a new commit to this branch that resolves the issue will result in pre-submit jobs being scheduled.

@flutter-zl
flutter-zl self-requested a review September 23, 2026 17:40
@flutter-zl flutter-zl added the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Sep 23, 2026
@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Sep 23, 2026
@github-actions github-actions Bot removed the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Sep 23, 2026
@flutter-dashboard

Copy link
Copy Markdown

This pull request executed golden file tests, but it has not been updated in a while (20+ days). Test results from Gold expire after as many days, so this pull request will need to be updated with a fresh commit in order to get results from Gold.

For more guidance, visit Writing a golden file test for package:flutter.

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

@flutter-zl

Copy link
Copy Markdown
Contributor

Thanks a lot for this, @koji-1009, and for the detailed write-up and videos. It's useful confirmation that pan-y plus
document overflow is enough to make mobile browsers collapse the address bar.

That said, I don't think we want to take this direction in the engine. Because Flutter keeps scrolling while the browser
scrolls a placeholder, it needs a second touch input pipeline next to PointerBinding and a lot of work to suppress the
side effects of the fake scroll. It would also be on for every mobile full-page app. And it fixes only the address bar
part of #69529. Tap-to-top and in-app browser sheets still don't work, because the page's scroll position doesn't match the content. It also conflicts with #193744, which changed the iOS sizing since your last merge.

The direction we're exploring instead is letting the browser own the page scroll, with Flutter mirroring it. There's an
experimental package for this: flutter-browser-scroll https://github.com/flutter-zl/flutter-browser-scroll. It uses the
same pan-y plus document overflow idea, but the document scroll is the real scroll, so there's no second input pipeline,
and apps opt in. I've verified it collapses the address bar on iOS Safari, Chrome on iOS, and Chrome on Android:
https://flutter-demo-26-after.web.app.

@koji-1009

koji-1009 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for looking at this, @flutter-zl.

This was a PoC, and leaving out an opt-in flag and any change to PointerBinding was deliberate, to test whether the approach works without touching the existing input path. I see those two points as a matter of how far this PR was built out rather than of the approach itself. If PointerBinding accepted Touch Events for touch input, the interception and the duplicated pointerdown handling here would go away, and it would no longer be a second pipeline.

Tap-to-top isn't handled here, as you say. But it doesn't work on current stable either, where the address bar never collapses, so I think it can be tracked and handled separately. On iOS native it is a separate path from touch input too: the engine detects the status bar tap with an offscreen UIScrollView and sends handleScrollToTop on flutter/status_bar.

One reason I made this an engine PR rather than a package was that the iOS address bar height wasn't reflected in the view size, which is the code that now conflicts with #193744. If the engine gets the height right, this becomes much easier to ship as an external package. A package can also take fixes to JS-side handling quickly, without waiting for an SDK release.

I've also tried the PoC you linked, and the approach looks good. That said, it seems to show the issue I struggled with most while making this PR: the frame rate drops while the address bar hides on iOS and Android. Here I kept the view size fixed during the bar animation and reported the covered strip as padding instead, so the app isn't relaid out while the bar moves. Also, since the document height there follows the page content, a page that fits on screen seems to leave nothing to scroll, so the bar doesn't appear to collapse on it. Here the spacer doesn't depend on the content, so the bar behaves the same on every page. I hope these are useful as a reference.

To sum up, I'm fine with this direction living in an external package, as long as the sizing is handled in the engine; tap-to-top can be tracked on its own. This PR was meant as a PoC to explore the approach, so I'm happy to close it 👍

@flutter-zl

Copy link
Copy Markdown
Contributor

Thanks for looking at this, @flutter-zl.

This was a PoC, and leaving out an opt-in flag and any change to PointerBinding was deliberate, to test whether the approach works without touching the existing input path. I see those two points as a matter of how far this PR was built out rather than of the approach itself. If PointerBinding accepted Touch Events for touch input, the interception and the duplicated pointerdown handling here would go away, and it would no longer be a second pipeline.

Tap-to-top isn't handled here, as you say. But it doesn't work on current stable either, where the address bar never collapses, so I think it can be tracked and handled separately. On iOS native it is a separate path from touch input too: the engine detects the status bar tap with an offscreen UIScrollView and sends handleScrollToTop on flutter/status_bar.

One reason I made this an engine PR rather than a package was that the iOS address bar height wasn't reflected in the view size, which is the code that now conflicts with #193744. If the engine gets the height right, this becomes much easier to ship as an external package. A package can also take fixes to JS-side handling quickly, without waiting for an SDK release.

I've also tried the PoC you linked, and the approach looks good. That said, it seems to show the issue I struggled with most while making this PR: the frame rate drops while the address bar hides on iOS and Android. Here I kept the view size fixed during the bar animation and reported the covered strip as padding instead, so the app isn't relaid out while the bar moves. Also, since the document height there follows the page content, a page that fits on screen seems to leave nothing to scroll, so the bar doesn't appear to collapse on it. Here the spacer doesn't depend on the content, so the bar behaves the same on every page. I hope these are useful as a reference.

To sum up, I'm fine with this direction living in an external package, as long as the sizing is handled in the engine; tap-to-top can be tracked on its own. This PR was meant as a PoC to explore the approach, so I'm happy to close it 👍

Thanks for looking into this. I am closing this PR.

@flutter-zl flutter-zl closed this Oct 21, 2026
@koji-1009
koji-1009 deleted the feat/web_ios_address_bar_callapse branch October 21, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants