Skip to content

[cupertino_ui] Add rubber-band physics simulation to CupertinoSheet when enableDrag is false - #12516

Draft
xxxOVALxxx wants to merge 3 commits into
flutter:mainfrom
xxxOVALxxx:sheet-spring-physics
Draft

[cupertino_ui] Add rubber-band physics simulation to CupertinoSheet when enableDrag is false#12516
xxxOVALxxx wants to merge 3 commits into
flutter:mainfrom
xxxOVALxxx:sheet-spring-physics

Conversation

@xxxOVALxxx

Copy link
Copy Markdown

This PR is stacked on top of #12515
Please review #12515 first

This PR adds native iOS-style rubber-banding resistance to CupertinoSheetRoute / showCupertinoSheet when dragging is disabled (enableDrag: false)

Flutter on the left, SwiftUI on the right

output.mp4
Flutter example
import 'package:cupertino_ui/cupertino_ui.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const CupertinoApp(
      theme: CupertinoThemeData(
        brightness: Brightness.light,
        primaryColor: CupertinoColors.systemBlue,
      ),
      home: ContentView(),
    );
  }
}

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

  void _openStandardSheet(BuildContext context) {
    showCupertinoSheet<void>(
      context: context,
      showDragHandle: true,
      enableDrag: true,
      scrollableBuilder:
          (BuildContext context, ScrollController scrollController) {
            return const StandardSheetView();
          },
    );
  }

  void _openNonDismissibleListSheet(BuildContext context) {
    showCupertinoSheet<void>(
      context: context,
      showDragHandle: false,
      enableDrag: false,
      scrollableBuilder:
          (BuildContext context, ScrollController scrollController) {
            return NonDismissibleListSheetView(
              scrollController: scrollController,
            );
          },
    );
  }

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: const CupertinoNavigationBar(
        middle: Text('Cupertino Sheet Demo'),
      ),
      child: SafeArea(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              SizedBox(
                width: double.infinity,
                child: CupertinoButton.filled(
                  borderRadius: BorderRadius.circular(12.0),
                  padding: const EdgeInsets.symmetric(vertical: 16.0),
                  onPressed: () => _openStandardSheet(context),
                  child: const Text(
                    '1. Open Standard Sheet',
                    style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
                  ),
                ),
              ),
              const SizedBox(height: 20),
              SizedBox(
                width: double.infinity,
                child: CupertinoButton.filled(
                  borderRadius: BorderRadius.circular(12.0),
                  padding: const EdgeInsets.symmetric(vertical: 16.0),
                  onPressed: () => _openNonDismissibleListSheet(context),
                  child: const Text(
                    '2. Open Sheet with List',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: const Text('Standard Sheet'),
        leading: CupertinoButton(
          padding: EdgeInsets.zero,
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('Close'),
        ),
      ),
      child: SafeArea(
        child: Center(
          child: Padding(
            padding: const EdgeInsets.all(24.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                const Icon(
                  CupertinoIcons.arrow_down_circle,
                  size: 64,
                  color: CupertinoColors.systemBlue,
                ),
                const SizedBox(height: 16),
                const Text(
                  'Standard Dismissible Sheet',
                  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 8),
                Text(
                  'Swipe down on the sheet to dismiss it.',
                  style: TextStyle(
                    fontSize: 15,
                    color: CupertinoColors.secondaryLabel.resolveFrom(context),
                  ),
                  textAlign: TextAlign.center,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class NonDismissibleListSheetView extends StatelessWidget {
  const NonDismissibleListSheetView({
    super.key,
    required this.scrollController,
  });

  final ScrollController scrollController;

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      backgroundColor: CupertinoColors.systemGroupedBackground,
      navigationBar: CupertinoNavigationBar(
        middle: const Text('enableDrag: false'),
        trailing: CupertinoButton(
          padding: EdgeInsets.zero,
          onPressed: () => Navigator.of(context).pop(),
          child: const Text(
            'Done',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
        ),
      ),
      child: SafeArea(
        bottom: false,
        child: ListView(
          controller: scrollController,
          physics: const AlwaysScrollableScrollPhysics(),
          children: <Widget>[
            CupertinoListSection.insetGrouped(
              hasLeading: false,
              children: List.generate(30, (int index) {
                final int itemIndex = index + 1;
                return CupertinoListTile(
                  title: Text('Item $itemIndex'),
                  subtitle: Text(
                    'Subtitle for item $itemIndex',
                    style: TextStyle(
                      color: CupertinoColors.secondaryLabel.resolveFrom(
                        context,
                      ),
                      fontSize: 13,
                    ),
                  ),
                  trailing: const CupertinoListTileChevron(),
                  onTap: () {},
                );
              }),
            ),
          ],
        ),
      ),
    );
  }
}
SwiftUI example
import SwiftUI

struct ContentView: View {
    @State private var isStandardSheetPresented = false
    @State private var isNonDismissibleSheetPresented = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Button {
                    isStandardSheetPresented = true
                } label: {
                    Text("1. Open Standard Sheet")
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.large)

                Button {
                    isNonDismissibleSheetPresented = true
                } label: {
                    Text("2. Open Sheet with List")
                        .multilineTextAlignment(.center)
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.large)
            }
            .padding(.horizontal, 20)
            .navigationTitle("Cupertino Sheet Demo")
            .navigationBarTitleDisplayMode(.inline)
            .sheet(isPresented: $isStandardSheetPresented) {
                StandardSheetView()
            }
            .sheet(isPresented: $isNonDismissibleSheetPresented) {
                NonDismissibleListSheetView()
            }
        }
    }
}

struct StandardSheetView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                Image(systemName: "arrow.down.circle")
                    .font(.system(size: 64))
                    .foregroundStyle(.blue)

                Text("Standard Dismissible Sheet")
                    .font(.title3)
                    .fontWeight(.bold)

                Text("Swipe down on the sheet to dismiss it.")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
            }
            .padding(24)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .navigationTitle("Standard Sheet")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close") {
                        dismiss()
                    }
                }
            }
        }
        .presentationDragIndicator(.visible)
    }
}

struct NonDismissibleListSheetView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            List {

                ForEach(1...30, id: \.self) { index in
                    HStack {
                        VStack(alignment: .leading, spacing: 2) {
                            Text("Item \(index)")
                                .font(.body)
                            Text("Subtitle for item \(index)")
                                .font(.footnote)
                                .foregroundStyle(.secondary)
                        }
                        Spacer()
                        Image(systemName: "chevron.right")
                            .font(.footnote)
                            .fontWeight(.semibold)
                            .foregroundStyle(.tertiary)
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                    }
                }
            }
            .navigationTitle("enableDrag: false")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Done") {
                        dismiss()
                    }
                    .fontWeight(.bold)
                }
            }
        }
        .presentationDragIndicator(.hidden)
        .interactiveDismissDisabled(true)
    }
}

Pre-Review 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.

Footnotes

  1. Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. 2

@xxxOVALxxx
xxxOVALxxx marked this pull request as draft August 19, 2026 23:10

@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 implements a rubber-banding and spring-back animation for Cupertino sheets when dragging is disabled, using Apple's rubber-banding formula and a physical spring simulation. It also ensures that sheets ignore gestures mid-dismissal. Feedback suggests declaring the global _sheetSpring variable as final, optimizing the squaring calculation in _computeRubberBandFriction by replacing math.pow with direct multiplication, and checking navigator.mounted before calling navigator.didStopUserGesture() to prevent potential exceptions during the animation.

Comment thread packages/cupertino_ui/lib/src/sheet.dart Outdated
Comment thread packages/cupertino_ui/lib/src/sheet.dart Outdated
Comment thread packages/cupertino_ui/lib/src/sheet.dart
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

p: cupertino_ui triage-design Should be looked at in design triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant