Chuyển đến nội dung chính

Custom scroll physics: making a list stop where you want it to

Scrolling is the interaction users feel most and describe least. “It feels sluggish”, “it doesn’t stop where I expect”, “it bounces wrong on Android”. Those complaints all point at one small class: ScrollPhysics.

Most Flutter developers only ever meet it through the three named subclasses — BouncingScrollPhysics, ClampingScrollPhysics, NeverScrollableScrollPhysics. Underneath, it is a compact interface with four decision points, and understanding those turns “the list stops in a weird place” from a mystery into a two-line fix.

What the class actually decides

MethodDecides
applyPhysicsToUserOffsetHow a finger’s movement maps to scroll offset — resistance when overscrolling
applyBoundaryConditionsHow much of a requested offset to refuse at the edges
createBallisticSimulationWhat happens after the finger lifts: fling, settle, snap, or nothing
toleranceWhen a simulation is considered finished

Plus two properties worth knowing: shouldAcceptUserOffset (can this be dragged at all) and minFlingVelocity / maxFlingVelocity (what counts as a fling).

The three built-ins differ almost entirely in the first three:

  • ClampingScrollPhysics — Android-style. applyBoundaryConditions refuses everything past the edge, producing a hard stop plus the glow indicator.
  • BouncingScrollPhysics — iOS-style. Boundary conditions allow going past the edge, applyPhysicsToUserOffset makes it progressively harder, and the ballistic simulation springs back.
  • NeverScrollableScrollPhysicsshouldAcceptUserOffset returns false. Note that this stops user scrolling only; a ScrollController.animateTo still works, which is exactly what you want for a programmatically driven view.

Which one you get by default depends on the platform, via ScrollConfiguration. That is why the same code feels different on iOS and Android — and why forcing one everywhere is a decision, not a fix.

Composition: the applyTo pattern

ScrollPhysics composes through a parent, and every override is expected to call through. This is why you write physics as a thin layer rather than a replacement:

class SnapScrollPhysics extends ScrollPhysics {
  const SnapScrollPhysics({required this.itemExtent, super.parent});

  final double itemExtent;

  @override
  SnapScrollPhysics applyTo(ScrollPhysics? ancestor) =>
      SnapScrollPhysics(itemExtent: itemExtent, parent: buildParent(ancestor));

  // ...
}

Forgetting applyTo is the most common mistake. Without it, your physics is silently replaced when the framework rebuilds the chain — for example when a ScrollConfiguration applies the platform default — and your customisation appears to work in some places and not others.

Usage is then ordinary:

ListView.builder(
  physics: const SnapScrollPhysics(itemExtent: 120)
      .applyTo(const BouncingScrollPhysics()),
  itemExtent: 120,
  itemCount: items.length,
  itemBuilder: /* ... */,
)

A snapping physics, complete

The most-requested custom physics is “snap to item boundaries”. Here it is in full, because the pieces only make sense together:

class SnapScrollPhysics extends ScrollPhysics {
  const SnapScrollPhysics({required this.itemExtent, super.parent});

  final double itemExtent;

  @override
  SnapScrollPhysics applyTo(ScrollPhysics? ancestor) =>
      SnapScrollPhysics(itemExtent: itemExtent, parent: buildParent(ancestor));

  double _snapTarget(ScrollMetrics position, double velocity) {
    // Where the fling would naturally land, then round to the nearest item.
    final current = position.pixels;
    final index = (current / itemExtent).round();
    final biased = velocity.abs() < tolerance.velocity
        ? index
        : (velocity > 0 ? index + 1 : index - 1);
    return (biased * itemExtent)
        .clamp(position.minScrollExtent, position.maxScrollExtent);
  }

  @override
  Simulation? createBallisticSimulation(
    ScrollMetrics position,
    double velocity,
  ) {
    // Let the parent handle overscroll — do not fight bounce-back.
    if (position.outOfRange) {
      return super.createBallisticSimulation(position, velocity);
    }

    final target = _snapTarget(position, velocity);
    if ((target - position.pixels).abs() < tolerance.distance) return null;

    return ScrollSpringSimulation(
      spring,
      position.pixels,
      target,
      velocity,
      tolerance: toleranceFor(position),
    );
  }

  @override
  bool get allowImplicitScrolling => false;
}

Three details carry the whole thing.

Returning null means “stop here”. If the current position is already within tolerance of the target, do not animate. Returning a simulation that immediately completes causes a visible micro-stutter.

Deferring to super when outOfRange. The parent physics owns bounce-back. Overriding it means writing your own spring back from overscroll, which will not match the platform.

ScrollSpringSimulation carries the incoming velocity. Passing velocity through is what makes a fast fling feel fast and a gentle release feel gentle. Dropping it — animating to the target with a fixed duration — is why hand-rolled snapping so often feels dead.

For paged content, do not write this at all: PageScrollPhysics already snaps to viewport-sized pages, and PageView uses it by default.

Resistance and boundaries

The other two methods matter less often, but when they do, nothing else will do.

class ResistantEdgePhysics extends ScrollPhysics {
  const ResistantEdgePhysics({super.parent});

  @override
  ResistantEdgePhysics applyTo(ScrollPhysics? ancestor) =>
      ResistantEdgePhysics(parent: buildParent(ancestor));

  @override
  double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
    if (position.outOfRange) {
      // Halve finger movement once past the edge.
      return offset * 0.5;
    }
    return super.applyPhysicsToUserOffset(position, offset);
  }

  @override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    const maxOverscroll = 120.0;
    if (value < position.minScrollExtent - maxOverscroll) {
      return value - (position.minScrollExtent - maxOverscroll);
    }
    if (value > position.maxScrollExtent + maxOverscroll) {
      return value - (position.maxScrollExtent + maxOverscroll);
    }
    return super.applyBoundaryConditions(position, value);
  }
}

applyBoundaryConditions returns the amount of the requested change to refuse, not the amount to allow. Returning 0.0 means “allow it all”. Getting this backwards produces a list that cannot be scrolled at all, which is a memorably confusing five minutes.

Applying physics widely

For an app-wide feel, override ScrollConfiguration rather than passing physics: everywhere:

class AppScrollBehavior extends MaterialScrollBehavior {
  @override
  ScrollPhysics getScrollPhysics(BuildContext context) =>
      const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics());
}

MaterialApp(
  scrollBehavior: AppScrollBehavior(),
  home: const HomePage(),
);

ScrollBehavior is also where you control the overscroll indicator, the scrollbar, and which input devices can drag — the last being the fix for “my desktop app cannot be scrolled by dragging with the mouse”, which is a dragDevices setting, not a physics one.

AlwaysScrollableScrollPhysics as a parent is worth knowing on its own: it makes a list scrollable even when its content is shorter than the viewport, which is what makes pull-to-refresh work on a nearly empty list.

Testing it

Physics is felt, not read, but the parts that matter can be asserted:

testWidgets('fling settles on an item boundary', (tester) async {
  await tester.pumpWidget(const SnapList());
  await tester.fling(find.byType(ListView), const Offset(0, -300), 800);
  await tester.pumpAndSettle();

  final position = tester
      .state<ScrollableState>(find.byType(Scrollable))
      .position;
  expect(position.pixels % 120, moreOrLessEquals(0, epsilon: 0.5));
});

pumpAndSettle runs the simulation to completion, so the assertion is on the resting position — which is exactly the property a snapping physics promises.

FAQ

Why does my custom physics work on one screen and not another?

Almost always a missing or incorrect applyTo. The framework rebuilds the physics chain in several places, and without applyTo yours is dropped.

Should I force iOS bouncing on Android?

It is a product decision, but the default is platform-matched for a reason: users compare your list to every other list on their device. Forcing one feel makes the app consistent with itself and inconsistent with the platform.

How do I disable scrolling temporarily?

NeverScrollableScrollPhysics for user input while keeping controller-driven scrolling. If you also want to block programmatic scrolling, do not scroll programmatically — physics is not the enforcement point.

Why does pumpAndSettle time out on my scroll test?

Usually a simulation that never reaches tolerance — a spring with the wrong parameters, or a createBallisticSimulation that keeps returning a new simulation. Return null when you are close enough.

Can I animate to a snapped position from a controller instead?

Yes, and for a one-off “snap after this action” that is simpler. Custom physics is for when every release should snap, which is a property of the scroll view, not of one interaction.


The ScrollPhysics interface, boundary-condition semantics, simulation classes and ScrollBehavior hooks described here are documented in the Flutter API references linked above. The snapping implementation, the emphasis on passing velocity through, and the diagnostic that a broken customisation is usually a missing applyTo are my own judgement from writing physics this way. Physics internals shift between Flutter releases — verify the class members against your SDK before copying.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

Announcing Flutter 2

  Phụ lục: Flutter on the web Flutter 2 on desktops, foldables, and embedded devices The growing Flutter ecosystem Dart: The secret sauce behind Flutter Flutter 2: Available now Hôm nay, chúng tôi sẽ công bố Flutter 2: một bản nâng cấp lớn cho Flutter cho phép các nhà phát triển tạo các ứng dụng đẹp, nhanh chóng và di động cho bất kỳ nền tảng nào. Với Flutter 2, bạn có thể sử dụng cùng một cơ sở mã để gửi các ứng dụng gốc cho năm hệ điều hành: IOS, Android, Windows, macOS và Linux; cũng như trải nghiệm web nhắm mục tiêu các trình duyệt như Chrome, Firefox, Safari hoặc Edge. Flutter thậm chí có thể được nhúng vào ô tô, TV và thiết bị gia dụng thông minh, mang đến trải nghiệm di động và lan tỏa nhất cho thế giới điện toán xung quanh. Mục tiêu của chúng tôi là thay đổi cơ bản cách các nhà phát triển nghĩ về việc xây dựng ứng dụng, bắt đầu không phải với nền tảng bạn đang nhắm mục tiêu mà là với trải nghiệm bạn muốn tạo. Flutter cho phép bạn tạo ra những trải nghiệm tuyệt đẹp trong đó ...