First, let’s look at the goals and the results

I put the activity space on top of the TabBar. As to why, ha ha, I’m afraid of trouble, because Meituan take-out activity of components and the component along with all the order goods below, evaluation, merchants page to switch, but it disappeared with the commodity page slide, including the main sliding components, we have to do let the sliding from the commodity list components through two levels, it is trouble. So I put the active components on top of the TabBar.

Then let’s analyze the page structure

Looking at the motion picture, we know that,TabBarThe content below is in the structure diagramBodySection) along with the page slide extension, internal also includes sliding components. When we look at this structure, it’s easy to imagineNestedScrollViewThis component. But directly usingNestedScrollViewThere are some problems. For example, let’s look at the sample code:

Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: NestedScrollView( headerSliverBuilder: (BuildContext context, bool boxIsScrolled) {return <Widget>[SliverAppBar(pinned: true, title: Text(" stay ",style: TextStyle(color: Colors.black)), backgroundColor: Colors.transparent, bottom: TabBar( controller: _tabController, labelColor: Colors. Black, tabs: <Widget>[Tab(text: ""), Tab(text:" "), Tab(text: ""),],)]; }, body: Container(color: color.blue, child: Center(child: Text(" body "),),),); }Copy the code

Look at the code. I’m going toSliverAppBarSet the background to transparent. The problem occurs when the page slides up and the Body part passes throughSliverAppBarandThe status barDown here, to the top of the screen. In this case, the result is definitely not what we want. In addition, due toNestedScrollViewThere’s only one insideScrollController(In the code belowinnerController),BodyAll the lists in itScrollPositionWill beattachTo thisScrollControllerGo, then we have another problem. OurgoodsThere are two lists in the page, and if they share a controller, thenScrollPositionI’m going to use the same one, and that’s not going to work, because the lists are different, so becauseNestedScrollViewThere’s only one insideScrollControllerThis, decided that we can not rely onNestedScrollViewTo achieve this effect. However,NestedScrollViewIt’s not useless to us, but it gives us a key idea. Why do you sayNestedScrollViewStill useful to us? Because of its properties,BodySections slide up and down the page,BodyThe bottom of the section is always at the bottom of the screen. Then thisBodyWhere did the height of the part come from? Let’s go check it out.NestedScrollViewThe code:

  List<Widget> _buildSlivers(BuildContext context,
      ScrollController innerController, bool bodyIsScrolled) {
    return <Widget>[
      ...headerSliverBuilder(context, bodyIsScrolled),
      SliverFillRemaining(
        child: PrimaryScrollController(
          controller: innerController,
          child: body,
        ),
      ),
    ];
  }
Copy the code

The body of NestedScrollView is in SliverFillRemaining, And that SliverFillRemaining is really the key for the body of the NestedScrollView to fill in between the front component and the bottom of the NestedScrollView. Okay, now that this guy exists, we can try to do something a little bit like NestedScrollView ourselves. I chose the outermost sliding component CustomScrollView. NestedScrollView is also inherited from CustomScrollView.

Implement an effect similar to that of NestedScrollView

First of all, we write a similar interface ShopPage with NestedScrollView structure, the key code is as follows:

class _ShopPageState extends State<ShopPage>{ @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( controller: _pageScrollController, physics: AlwaysScrollableScrollPhysics(), slivers: <Widget>[SliverAppBar(pinned: true, title: Text(" stay pinned ", style: TextStyle(color: color.white)), backgroundColor: Colors.blue, expandedHeight: 300), SliverFillRemaining( child: ListView.builder( controller: _childScrollController, padding: EdgeInsets.all(0), physics: AlwaysScrollableScrollPhysics(), shrinkWrap: True, itemExtent: 100.0, itemCount: 30, itemBuilder: (Context, index) => Container(padding: EdgeInsets. Symmetric (horizontal: 1), Child: Material(elevation: 4.0, borderRadius: borderRadius. Circular (5.0), color: symmetric(horizontal: 1), Child: Material(elevation: 4.0, borderRadius: borderRadius. index % 2 == 0 ? Colors.cyan : Colors.deepOrange, child: Center(child: Text(index.toString())), )))) ], ), ); }} page structure sliding effectCopy the code

As you can see from the GIF, slide belowListViewCan’t driveCustomScrollViewIn theSliverAppBarScaling. How do we do that? First think about the effect we want:

  • You slideListViewIf theSliverAppBarIt’s in the expanded state, so let’s go firstSliverAppBarContraction, whenSliverAppBarWhen it can’t shrink,ListViewIt rolls.
  • Slide downListViewwhenListViewWhen I get to the first one I can’t slide anymore,SliverAppBarI should expand it untilSliverAppBarFully unfolded.

Should the SliverAppBar respond and expand or shrink if it does. We definitely need to judge according to the sliding direction and the sliding distance between CustomScrollView and ListView. So we need a tool to coordinate how they respond based on who initiated the slide, the status of the CustomScrollView and the ListView, the direction of the slide, the distance of the slide, the speed of the slide, etc.

As for how to write this coordinator, let’s not worry about it. We should understand the sliding component principle.

Create a nested sliding PageView from scratch

Implement a nested sliding PageView from scratch (2)

Create a nested sliding PageView from scratch

The roll of a Flutter and the sliver constraint

After reading these articles, combined with our usage scenarios, we need to understand:

  • When you swipe your finger across the screen,ScrollerPositionIn theapplyUserOffsetMethod yields the slide vector;
  • When the finger leaves the screen,ScrollerPositionIn thegoBallisticMethod will get the finger swipe speed before leaving the screen;
  • From beginning to end, the sliding events initiated by the main sliding component do not interfere with the sub-sliding components, so we only need to send the sub-components’ events to the coordinator for analysis and coordination during coordination.

So basically, we need to change ScrollerPosition, ScrollerController. The ScrollerPosition is modified to pass the finger slide distance or the finger slide speed before leaving the screen to the coordinator for coordination processing. The ScrollerController is changed to make sure that when the slide controller creates the ScrollerPosition it creates the ScrollerPosition that we’ve modified. So, here we go!

Realize the sub-component sliding up and down to associate the main component

First, assume that our coordinator class is named ShopScrollCoordinator.

Slide controller ShopScrollerController

Let’s copy the source code for ScrollerController, and then change the class name to ShopScrollController for easy differentiation. The controller needs to be modified as follows:

class ShopScrollController extends ScrollController { final ShopScrollCoordinator coordinator; Coordinator, {double initialScrollOffset = 0.0, this.keepScrollOffset = true, this.debugLabel, }) : assert(initialScrollOffset ! = null), assert(keepScrollOffset ! = null), _initialScrollOffset = initialScrollOffset; ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition) { return ShopScrollPosition( coordinator: coordinator, physics: physics, context: context, initialPixels: initialScrollOffset, keepScrollOffset: keepScrollOffset, oldPosition: oldPosition, debugLabel: debugLabel, ); } // leave the rest of the code untouched.Copy the code

Slide scroll position ShopScrollPosition

The original ScrollerController create ScrollPosition ScrollPositionWithSingleContext. We go to copy ScrollPositionWithSingleContext source code, and then in order to facilitate distinguish, we changed the name of the class to ShopScrollPosition. The previous said, we mainly need to modify the applyUserOffset, goBallistic two methods.

class ShopScrollPosition extends ScrollPosition implements ScrollActivityDelegate { final ShopScrollCoordinator coordinator; // ShopScrollPosition({@required this.coordinator, @required ScrollPhysics physics, @required ScrollContext Context, double initialPixels = 0.0, bool keepScrollOffset = true, ScrollPosition oldPosition, String debugLabel}) : super( physics: physics, context: context, keepScrollOffset: keepScrollOffset, oldPosition: oldPosition, debugLabel: debugLabel, ) { if (pixels == null && initialPixels ! = null) correctPixels(initialPixels); if (activity == null) goIdle(); assert(activity ! = null); } // [delta] = [delta] = [delta] = [delta] = [delta] = [delta] = [delta] = [delta] = [delta] = [delta] @override void applyUserOffset(double delta) {ScrollDirection userScrollDirection = delta > 0.0? ScrollDirection.forward : ScrollDirection.reverse; if (debugLabel ! = coordinator.pageLabel) return coordinator.applyUserOffset(delta, userScrollDirection, this); updateUserScrollDirection(userScrollDirection); setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta)); } /// Start a physically-driven simulation at a specific speed. This simulation determines the [Pixels] position. / / / this method conform to [ScrollPhysics createBallisticSimulation], this method usually provide when the current position is beyond the scope / / / slide simulation, and in the current position is beyond range but has a nonzero speed friction simulation. /// The speed should be in logical pixels per second. /// [velocity] @override void goBallistic(double Velocity, [bool fromCoordinator = false]) {if (debugLabel! If (velocity > 0.0) coordinator.goBallistic(velocity); } else {if (fromCoordinator && Velocity <= 0.0) return; } assert(pixels ! = null); final Simulation simulation = physics.createBallisticSimulation(this, velocity); if (simulation ! = null) { beginActivity(BallisticScrollActivity(this, simulation, context.vsync)); } else { goIdle(); }} /// Returns an unused increment. // Copy double applyClampedDragUpdate(double delta) from [NestedScrollView's custom [ScrollPosition][_NestedScrollPosition] { assert(delta ! = 0.0). Final double min = delta < 0.0? -double.infinity : math.min(minScrollExtent, pixels); Final double Max = delta > 0.0? double.infinity : math.max(maxScrollExtent, pixels); final double oldPixels = pixels; final double newPixels = (pixels - delta).clamp(min, max) as double; final double clampedDelta = newPixels - pixels; If (clampedDelta == 0.0) return delta; final double overScroll = physics.applyBoundaryConditions(this, newPixels); final double actualNewPixels = newPixels - overScroll; final double offset = actualNewPixels - oldPixels; if (offset ! = 0.0) {forcePixels (actualNewPixels); didUpdateScrollPositionBy(offset); } return delta + offset; } /// return excessive scrolling. Double applyFullDragUpdate(double delta) {// copy double applyFullDragUpdate(double delta) from [NestedScrollView's custom [ScrollPosition][_NestedScrollPosition] { assert(delta ! = 0.0). final double oldPixels = pixels; / / the Apply friction: applying friction: final double newPixels = pixels - physics. ApplyPhysicsToUserOffset (this, delta); If (oldPixels == newPixels) return 0.0; / / Check for overScroll: Check excessive rolling: final double overScroll = physics. ApplyBoundaryConditions (this, newPixels); final double actualNewPixels = newPixels - overScroll; if (actualNewPixels ! = oldPixels) { forcePixels(actualNewPixels); didUpdateScrollPositionBy(actualNewPixels - oldPixels); } return overScroll; }}Copy the code

Slide coordinator ShopScrollCoordinator

Class ShopScrollCoordinator {/// Final String pageLabel = "page"; ShopScrollController pageScrollController([double initialOffset = 0.0]) {assert(initialOffset! = null, initialOffset >= 0.0); _pageInitialOffset = initialOffset; _pageScrollController = ShopScrollController(this, debugLabel: pageLabel, initialScrollOffset: initialOffset); return _pageScrollController; NewChildScrollController ([String debugLabel]) => ShopScrollController(this, debugLabel: debugLabel); /// [delta] Sliding distance /// [userScrollDirection] User sliding direction /// [position] Position of the sliding component void applyUserOffset(double) delta, [ScrollDirection userScrollDirection, ShopScrollPosition position]) {if (userScrollDirection == scrollDirection.reverse) {/// When the user is sliding upwards updateUserScrollDirection(_pageScrollPosition, userScrollDirection); final innerDelta = _pageScrollPosition.applyClampedDragUpdate(delta); if (innerDelta ! = 0.0) {updateUserScrollDirection (position, userScrollDirection); position.applyFullDragUpdate(innerDelta); }} else {/ / / when the user sliding direction is downward slide updateUserScrollDirection (position, userScrollDirection); final outerDelta = position.applyClampedDragUpdate(delta); if (outerDelta ! = 0.0) {updateUserScrollDirection (_pageScrollPosition userScrollDirection); _pageScrollPosition.applyFullDragUpdate(outerDelta); }}}}Copy the code

Now we add code to _ShopPageState:

Class _ShopPageState extends State<ShopPage>{// page slide coordinator ShopScrollCoordinator _shopCoordinator; // ShopscrollController_pagescrollController; // ShopscrollController_childscrollController; /// Add the controller !!!! to the CustomScrollView and ListView in the build method @override void initState() { super.initState(); _shopCoordinator = ShopScrollCoordinator(); _pageScrollController = _shopCoordinator.pageScrollController(); _childScrollController = _shopCoordinator.newChildScrollController(); } @override void dispose() { _pageScrollController? .dispose(); _childScrollController? .dispose(); super.dispose(); }}Copy the code

At this time, the basic implementation of the implementation of the sub-component sliding up and down associated with the main component. The effect is as follows:

Realize the Body structure of meituan takeout ordering page

Modify the contents of SliverFillRemaining in _ShopPageState:

// add a new controller!! SliverFillRemaining( child: Row( children: <Widget>[ Expanded( child: ListView.builder( controller: _childScrollController, padding: EdgeInsets.all(0), physics: AlwaysScrollableScrollPhysics(), shrinkWrap: true, itemExtent: 50, itemCount: 30, itemBuilder: (context, index) => Container( padding: EdgeInsets. Symmetric (horizontal: 1), Child: Material(elevation: 4.0, borderRadius: borderRadius. Circular (5.0), color: symmetric(horizontal: 1), Child: Material(elevation: 4.0, borderRadius: borderRadius. index % 2 == 0 ? Colors.cyan : Colors.deepOrange, child: Center(child: Text(index.toString())), )))), Expanded( flex: 4, child: ListView.builder( controller: _childScrollController1, padding: EdgeInsets.all(0), physics: AlwaysScrollableScrollPhysics(), shrinkWrap: true, itemExtent: 150, itemCount: 30, itemBuilder: (context, index) => Container( padding: EdgeInsets.symmetric(horizontal: 1), child: Material( elevation: 4.0, borderRadius: borderRadius. Circular (5.0), color: index % 2 == 0? Center(child: Text(index.toString())), )))) ], ))Copy the code

See the effectThere seems to be some problem. What is it? When I only slide up the right sub-part, whenSliverAppBarWe can see that the first child on the left is not zero. As shown in figure:On the surface of the frontNestedScrollViewIs the same problem. So how do we solve this? Change of bai!Inspired by Flutter Candies in a bucketAdd coordinator method:

/ / / get the body component height double suction a top before the Function () pinnedHeaderSliverHeightBuilder; bool applyContentDimensions(double minScrollExtent, double maxScrollExtent, ShopScrollPosition position) { if (pinnedHeaderSliverHeightBuilder ! = null) { maxScrollExtent = maxScrollExtent - pinnedHeaderSliverHeightBuilder(); MaxScrollExtent = math. Max (0.0, maxScrollExtent); } return position.applyContentDimensions( minScrollExtent, maxScrollExtent, true); }Copy the code

Modify the applyContentDimensions method of ShopScrollPosition:

@override bool applyContentDimensions(double minScrollExtent, double maxScrollExtent, [bool fromCoordinator = false]) { if (debugLabel == coordinator.pageLabel && ! fromCoordinator) return coordinator.applyContentDimensions( minScrollExtent, maxScrollExtent, this); return super.applyContentDimensions(minScrollExtent, maxScrollExtent); }Copy the code

In this case, we just need to initialize the page coordinator and assign it a function that returns the sum of the folded heights of all lock top components before the body.

Realize the full-screen display of store information on the page head of Meituan takeaway store

The target is shown as follows:Why is it full screen, and I don’t need to go into that, but the gray around the expanded card is apaddingAnd nothing more. The usedSliverAppBarPeople basically can think of, will itexpandedHeightSetting this to screen height allows the head to fill the entire screen as it expands. However, in the pageSliverAppBarIt’s not fully expanded by default, and it’s certainly not fully shrunk, which would leave the thing with an AppBar at the top. So how do we make it look like Meituan by default? Remember oursScrollControllerThe constructor of theinitialScrollOffsetPassable parameters, hey hey, as long as we set the controller of the main page sliderinitialScrollOffset, the page will be set by defaultinitialScrollOffsetCorresponding position. Ok, the default position is ok. However, the driven diagram can be seen when we pull down the part to makeDefault position < main component sliding distance < maximum height of expansionAnd when you release your fingers,SliverAppBarIt will continue to unfold toMaximum height of development. So we definitely want to capture the finger off screen event. At this point, we can use itListenerThe component packageCustomScrollViewAnd then inListenertheonPointerUpGets the finger off screen event in. All right, so there we go. Let’s see how this works:

Add enumerations outside the coordinator:

enum PageExpandState { NotExpand, Expanding, Expanded }
Copy the code

Coordinator adds code:

/// double _pageInitialOffset; ShopScrollController pageScrollController([double initialOffset = 0.0]) {assert(initialOffset! = null, initialOffset >= 0.0); _pageInitialOffset = initialOffset; _pageScrollController = ShopScrollController(this, debugLabel: pageLabel, initialScrollOffset: initialOffset); return _pageScrollController; } // When the default position is not 0, the pull-down distance of the main component exceeds the default position, but the exceeded distance is not greater than this value, /// If the finger leaves the screen, the head of the main component will bounce back to the default position double _scrollRedundancy = 80; / / / the current page Header maximum expansion state PageExpandState pageExpand = PageExpandState. NotExpand; / / / when fingers left screen void onPointerUp (PointerUpEvent event) {final double _pagePixels = _pageScrollPosition. Pixels; If (0.0 < _pagePixels && _pagePixels < _pageInitialOffset) {if (pageExpand == pageExpand.NotExpand && _pageInitialOffset - _pagePixels > _scrollRedundancy) {_pagesCrollPosition. animateTo(0.0, duration: const Duration(milliseconds: 400), curve: Curves.ease) .then((value) => pageExpand = PageExpand.Expanded); } else { pageExpand = PageExpand.Expanding; _pageScrollPosition .animateTo(_pageInitialOffset, duration: const Duration(milliseconds: 400), curve: Curves.ease) .then((value) => pageExpand = PageExpand.NotExpand); }}}Copy the code

At this point, we pass the coordinator’s onPointerUp method to the Listener’s onPointerUp method, and we have basically achieved the desired effect. But, after testing, it actually has a slight problem. Sometimes when you release your finger it doesn’t automatically unfold or return to the default position as you expect. What’s the problem? We know that as we swipe the list and then leave the screen, the goBallistic method of the ScrollPosition is called, so as soon as onPointerUp is called, the goBallistic method is called, when the absolute velocity of the goBallistic incoming is very small, Then the simulated slide distance of the list is very, very small, even 0.0. So what’s the result? It just comes to mind.

We also need to modify the goBallistic method for ShopScrollPosition:

@override
void goBallistic(double velocity, [bool fromCoordinator = false]) {
  if (debugLabel != coordinator.pageLabel) {
    if (velocity > 0.0) coordinator.goBallistic(velocity);
  } else {
    if (fromCoordinator && velocity <= 0.0) return;
    if (coordinator.pageExpand == PageExpandState.Expanding) return;
  }
  assert(pixels != null);
  final Simulation simulation =
      physics.createBallisticSimulation(this, velocity);
  if (simulation != null) {
    beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
  } else {
    goIdle();
  }
}
Copy the code

Remember the pageinitStateIs initialized_pageScrollControllerRemember to pass in the value of the default location. Note at this point that the value of the default location is not the default state of the pageSliverAppBarThe bottom is the distance from the top of the screen, but the height of the screen minus the distance from the bottom to the top of the screen, i.einitialOffset = screenHeight - xAnd thisxWe set it up according to design or how we feel. I’m going to take 200. Come on, let’s see how it goes!!

Github link flutter_meituan_shop