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,TabBar
The content below is in the structure diagramBody
Section) along with the page slide extension, internal also includes sliding components. When we look at this structure, it’s easy to imagineNestedScrollView
This component. But directly usingNestedScrollView
There 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 toSliverAppBar
Set the background to transparent. The problem occurs when the page slides up and the Body part passes throughSliverAppBar
andThe status bar
Down here, to the top of the screen. In this case, the result is definitely not what we want. In addition, due toNestedScrollView
There’s only one insideScrollController
(In the code belowinnerController
),Body
All the lists in itScrollPosition
Will beattach
To thisScrollController
Go, then we have another problem. Ourgoods
There are two lists in the page, and if they share a controller, thenScrollPosition
I’m going to use the same one, and that’s not going to work, because the lists are different, so becauseNestedScrollView
There’s only one insideScrollController
This, decided that we can not rely onNestedScrollView
To achieve this effect. However,NestedScrollView
It’s not useless to us, but it gives us a key idea. Why do you sayNestedScrollView
Still useful to us? Because of its properties,Body
Sections slide up and down the page,Body
The bottom of the section is always at the bottom of the screen. Then thisBody
Where did the height of the part come from? Let’s go check it out.NestedScrollView
The 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 belowListView
Can’t driveCustomScrollView
In theSliverAppBar
Scaling. How do we do that? First think about the effect we want:
- You slide
ListView
If theSliverAppBar
It’s in the expanded state, so let’s go firstSliverAppBar
Contraction, whenSliverAppBar
When it can’t shrink,ListView
It rolls. - Slide down
ListView
whenListView
When I get to the first one I can’t slide anymore,SliverAppBar
I should expand it untilSliverAppBar
Fully 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,
ScrollerPosition
In theapplyUserOffset
Method yields the slide vector; - When the finger leaves the screen,
ScrollerPosition
In thegoBallistic
Method 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, whenSliverAppBar
We can see that the first child on the left is not zero. As shown in figure:On the surface of the frontNestedScrollView
Is 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 apadding
And nothing more. The usedSliverAppBar
People basically can think of, will itexpandedHeight
Setting this to screen height allows the head to fill the entire screen as it expands. However, in the pageSliverAppBar
It’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 oursScrollController
The constructor of theinitialScrollOffset
Passable parameters, hey hey, as long as we set the controller of the main page sliderinitialScrollOffset
, the page will be set by defaultinitialScrollOffset
Corresponding 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 expansion
And when you release your fingers,SliverAppBar
It 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 itListener
The component packageCustomScrollView
And then inListener
theonPointerUp
Gets 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 pageinitState
Is initialized_pageScrollController
Remember 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 pageSliverAppBar
The 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 - x
And thisx
We 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