本文主要是介绍Flutter - 循序渐进 Sliver,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
列表组件在移动端上尤为重要,Sliver 作为 Flutter 列表组件中重要的一部分,开发者非常有必要了解 Sliver 的原理和用法。
两种类型的布局
Flutter 的布局可以分为两种:
- Box ( RenderBox ): 2D 绘制布局
- Sliver ( RenderSliver ):滚动布局
重要的概念
Sliver
Sliver 是 Flutter 中的一个概念,表示可滚动布局中的一部分,它的 child 可以是普通的 Box Widget。
ViewPort
- ViewPort 是一个显示窗口,它内部可包含多个 Sliver;
- ViewPort 的宽高是确定的,它内部 Slivers 的宽高之和是可以大于自身的宽高的;
- ViewPort 为了提高性能采用懒加载机制,它只会绘制可视区域内容 Widget。
ViewPort 有一些重要属性:
class Viewport extends MultiChildRenderObjectWidget {/// 主轴方向final AxisDirection axisDirection;/// 纵轴方向final AxisDirection crossAxisDirection;/// center 决定 viewport 的 zero 基准线,也就是 viewport 从哪个地方开始绘制,默认是第一个 sliver/// center 必须是 viewport slivers 中的一员的 keyfinal Key center;/// 锚点,取值[0,1],和 zero 的相对位置,比如 0.5 代表 zero 被放到了 Viewport.height / 2 处final double anchor;/// 滚动的累计值,确切的说是 viewport 从什么地方开始显示final ViewportOffset offset;/// 缓存区域,也就是相对有头尾需要预加载的高度final double cacheExtent;/// children widgetList<Widget> slivers;}
复制代码
一图胜千言:
上图中假设每个 sliver 的 height 都相等且等于屏幕高度的 ⅕,这样设置 center = sliver1,屏幕的第一个显示的应该是 sliver 1,但是因为 anchor = 0.2,0.2 * viewport.height 正好等于 sliver1 的高度,所以屏幕上显示的第一个是 sliver 2。
ScrollPostion
ScrollPosition 决定了 Viewport 哪些区域是可见的,它包含了Viewport 的滚动信息,它的主要成员变量如下:
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {// 滚动偏移量double _pixels;// 设置滚动响应效果,比如滑动停止后的动画final ScrollPhysics physics;// 保存当前的滚动偏移量到 PageStore 中,当 Scrollable 重建后可以恢复到当前偏移量final bool keepScrollOffset;// 最小滚动值double _minScrollExtent;// 最大滚动值double _maxScrollExtent;...
}
复制代码
ScrollPosition 的类继承关系如下:
|-- Listenable
|---- ChangeNotifier
|------ ScrollPosition
|-------- ScrollPositionWithSingleContext
复制代码
所以 ScrollPosition 可以作为被观察者,当数据改变的时候可以通知观察者。
Scrollable
Scrollable 是一个可滚动的 Widget,它主要负责:
- 监听用户的手势,计算滚动状态发出 Notification
- 计算 offset 通知 listeners
Scrollable 本身不具有绘制内容的能力,它通过构造注入的 viewportBuilder 来创建一个 Viewport 来显示内容,当滚动状态变化的时候,Scrollable 就会不断的更新 Viewport 的 offset ,Viewport 就会不断的更新显示内容。
Scrollable 主要结构如下:
Widget result = _ScrollableScope(scrollable: this,position: position,child: Listener(onPointerSignal: _receivedPointerSignal,child: RawGestureDetector(gestures: _gestureRecognizers,...,child: Semantics(...child: IgnorePointer(...child: widget.viewportBuilder(context, position),),),),),);
复制代码
- _ScrollableScope 继承自 InheritedWidget,这样它的 children 可以方便的获取 scrollable 和 position;
- RawGestureDetector 负责手势监听,手势变化时会回调 _gestureRecognizers;
- viewportBuilder 会生成 viewport;
SliverConstraints
和 Box 布局使用 BoxConstraints 作为约束类似,Sliver 布局采用 SliverConstraints 作为约束,但相对于 Box 要复杂的多,可以理解为 SliverConstraints 描述了 Viewport 和它内部的 Slivers 之间的布局信息:
class SliverConstraints extends Constraints {// 主轴方向final AxisDirection axisDirection;// 窗口增长方向final GrowthDirection growthDirection;// 如果 Direction 是 AxisDirection.down,scrollOffset 代表 sliver 的 top 滑过 viewport 的 top 的值,没滑过 viewport 的 top 时 scrollOffset 为 0。final double scrollOffset;// 上一个 sliver 覆盖下一个 sliver 的大小(只有上一个 sliver 是 pinned/floating 才有效)final double overlap;// 轮到当前 sliver 开始绘制了,需要 viewport 告诉 sliver 当前还剩下多少区域可以绘制,受 viewport 的 size 影响final double remainingPaintExtent;// viewport 主轴上的大小final double viewportMainAxisExtent;// 缓存区起点(相对于 scrolloffset),如果 cacheExtent 设置为 0,那么 cacheOrigin 一直为 0final double cacheOrigin;// 剩余的缓存区大小final double remainingCacheExtent;...
}
复制代码
上图中的 sliver1 会被 SliverAppBar(pinned = true)遮住,遮住的大小就是 overlap,此时 overlap 会一直大于 0,如果设置像 iOS bouncing 那样的滑动效果,那么当 list 滚动到顶部继续滑动的时候 overlap 会小于 0(此刻并没有东西遮盖 sliver1,而是 sliver1 的 top 和 viewport 的 top 有间距)。
SliverGeometry
Viewport 通过 SliverConstraints 告知它内部的 sliver 自己的约束信息,比如还有多少空间可用、offset 等,那么Sliver 则通过 SliverGeometry 反馈给 Viewport 需要占用多少空间量。
class SliverGeometry extends Diagnosticable {// sliver 可以滚动的范围,可以认为是 sliver 的高度(如果是 AxisDierction.Down) final double scrollExtent;// 绘制起点(默认是 0.0),是相对于 sliver 开始 layout 的起点而言的,不会影响下一个 sliver 的 layoutExtent,会影响下一个 sliver 的paintExtentfinal double paintOrigin;// 绘制范围final double paintExtent;// 布局范围,当前 sliver 的 top 到下一个 sliver 的 top 的距离,范围是[0,paintExtent],默认是 paintExtent,会影响下一个 sliver 的 layout 位置final double layoutExtent;// 最大绘制大小,必须 >= paintExtentfinal double maxPaintExtent;// 如果 sliver 被 pinned 在边界的时候,这个大小为 Sliver 的自身的高度,其他情况为0,比如 pinned app barfinal double maxScrollObstructionExtent;// 点击有效区域的大小,默认为paintExtentfinal double hitTestExtent;// 是否可见,visible = (paintExtent > 0)final bool visible;// 是否需要做clip,免得chidren溢出final bool hasVisualOverflow;// 当前 sliver 占用了 SliverConstraints.remainingCacheExtent 多少像素值final double cacheExtent;...
}
复制代码
Sliver 布局过程
RenderViewport 在 layout 它内部的 slivers 的过程如下:
这个 layout 过程是一个自上而下的线性过程:
- 给 sliver1 输入 SliverConstrains1 并且得到输出结果(SliverGeometry1) ,
- 根据 SliverGeometry1 重新生成一个新的 SliverConstrains2 输入给 sliver2 得到 SliverGeometry2
- …
- 直至最后一个 sliver 具体的过程可以查看 RenderViewport 的 layoutChildSequence 方法。
ScrollView
以 ScrollView 为例,我们串联上面介绍的几个 Widget 之间的关系。 先来看 ScrollView 的 build 方法:
@overrideWidget build(BuildContext context) {final List<Widget> slivers = buildSlivers(context);final AxisDirection axisDirection = getDirection(context);final ScrollController scrollController = primary? PrimaryScrollController.of(context): controller;final Scrollable scrollable = Scrollable(...controller: scrollController,viewportBuilder: (BuildContext context, ViewportOffset offset) {return buildViewport(context, offset, axisDirection, slivers);},);return primary && scrollController != null? PrimaryScrollController.none(child: scrollable): scrollable;}
复制代码
可以看到 ScrollView 创建了一个 Scrollable,并传入了构造 ViewPort 的 buildViewPort 方法。 上面讲过 Scrollable 负责手势监听,通过 buildViewPort 创建视图,在手势变化的时候不停的更新 ViewPort,大概流程如下:
自定义 Sliver
CustomPinnedHeader 光看一些概念会难以理解,最好的方式是 debug 一下,我们可以 copy 一下 SliverToBoxAdapter 的代码自定义一个 Sliver 调试一下各个参数加深理解。
class CustomSliverWidget extends SingleChildRenderObjectWidget {const CustomSliverWidget({Key key, Widget child}): super(key: key, child: child);@overrideRenderObject createRenderObject(BuildContext context) {return CustomSliver();}
}
/// 一个 StickPinWidget
/// 主要讲述 Sliveronstraints 和 SliverGeometry 参数的作用
class CustomSliver extends RenderSliverSingleBoxAdapter {@overridevoid performLayout() {...// 将 SliverConstraints 转化为 BoxConstraints 对 child 进行 layoutchild.layout(constraints.asBoxConstraints(), parentUsesSize: true);...// 计算绘制大小final double paintedChildSize =calculatePaintOffset(constraints, from: 0.0, to: childExtent);// 计算缓存大小final double cacheExtent =calculateCacheOffset(constraints, from: 0.0, to: childExtent);...// 输出 SliverGeometry geometry = SliverGeometry(scrollExtent: childExtent,paintExtent: paintedChildSize,cacheExtent: cacheExtent,maxPaintExtent: childExtent,paintOrigin: 0,hitTestExtent: paintedChildSize,hasVisualOverflow: childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,);setChildParentData(child, constraints, geometry);}
}复制代码
我们把它放到 CustomScrollView 中:
eturn Scaffold(body: CustomScrollView(slivers: <Widget>[CustomSliverWidget(child: Container(color: Colors.red,height: 100,child: Center(child: Text("CustomSliver"),),)),_buildListView(),],));复制代码
效果如下:
我们修改 paintOrigin 为 10 的话,发现 CustomSliverWidget 的 layout 位置没有变,但绘制的起始点下移了 10 px,并且它下一个的 Sliver - item0 的 layout 没有被影响,但是 paint 时被遮住了一部分:
再做一个简单的修改,将 sliver 的绘制起始位置改为滑动的偏移量:
geometry = SliverGeometry(...paintOrigin: constraints.scrollOffset,visible: true,);
复制代码
此时你会发现 CustomSliver 可以固定在头部:
我们尝试修改 paintExtrent 如下:
geometry = SliverGeometry(//将绘制范围改为 sliver 的高度paintExtent: childExtent,...);
复制代码
在滑动的过程,CustomSliver 只是绘制变了,layout 没有变,导致下面 item0 没有被滑动,这是因为 layoutExtent 默认等于 paintExtent,我们将 paintExtent 赋值了常量,滑动过程中只有 paintOrigin 在改变,layout 的初始位置和高度并没有改变,它会一直占据着位置。
CustomRefreshWidget
接下来我们再做一个简单的下拉刷新 Widget,效果很简单,下拉的时候显示,释放的时候缩回:
class CustomRefreshWidget extends SingleChildRenderObjectWidget {const CustomRefreshWidget({Key key, Widget child}): super(key: key, child: child);@overrideRenderObject createRenderObject(BuildContext context) {return SimpleRefreshSliver();}
}/// 一个简单的下拉刷新 Widget
class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter {@overridevoid performLayout() {...final bool active = constraints.overlap < 0.0;/// 头部滑动的距离final double overscrolledExtent =constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;double layoutExtent = child.size.height;print("overscrolledExtent:${overscrolledExtent - layoutExtent}");child.layout(constraints.asBoxConstraints(maxExtent: layoutExtent + overscrolledExtent,),parentUsesSize: true,);if (active) {geometry = SliverGeometry(scrollExtent: layoutExtent,/// 绘制起始位置paintOrigin: min(overscrolledExtent - layoutExtent, 0),paintExtent: max(max(child.size.height, layoutExtent) ,0.0,),maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,),/// 布局占位layoutExtent: min(overscrolledExtent, layoutExtent),);} else {/// 如果不想显示可以直接设置为 zerogeometry = SliverGeometry.zero;}setChildParentData(child, constraints, geometry);}
}
复制代码
可以看到有3个关键的参数
- constraints.overlap:List 第一个 Sliver 的 top 距离屏幕 top 的距离
- paintOrigin:RefreshWidget 的绘制起始位置
- layoutExtent:RefreshWidget 的高度
items 的 top 与屏幕顶部的距离就是 constraints.overlap,它是一个小于等于 0 的值。
- 未操作时,overlap == 0,直接返回一个空 Widget(SliverGeometry.zero)
- 下拉时,overlap < 0, 这时候将 paintOrigin = min(overscrolledExtent - RefreshWidget.height, 0) 就可以让 RefreshWidget 慢慢的拉下来。
- 处理完 Paint 后,不要忘记处理 layout,前面说过,SliverGeometry 的 layoutExtent 会影响下一个 Sliver 的布局位置,所以 layoutExtent 也需要随着滑动而逐渐变大 layoutExtent = min(-overlap, RefreshWidget.height)
Scrolling Widget
常用的 List 如下,我们按照它包裹的内容分成了 3 类:
ListView
ListView.builder(itemCount: 50,itemBuilder: (context,index) {return Container(color: ColorUtils.randomColor(),height: 50,);}
复制代码
CustomScrollView
CustomScrollView(slivers: <Widget>[SliverAppBar(...),SliverToBoxAdapter(child:ListView(...),),SliverList(...),SliverGrid(...),],)
复制代码
NestedScrollView
NestedScrollView 其实里面是一个CustomScrollView,它的 headers 是 Sliver 的数组,body是被包裹在 SliverFillRemaining 中的,body 可以接受 Box。
NestedScrollView(headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {return <Widget>[SliverAppBar(expandedHeight: 100,pinned: true,title: Text("Nest"),),SliverToBoxAdapter(child: Text("second bar"),)];},body: ListView.builder(itemCount: 20,itemBuilder: (BuildContext context, int index) {return Text("item: $index");}),);复制代码
设计 CustomScrollView 的原因
复杂列表嵌套
如果直接使用 ListView 嵌套 ListView 会报错:
Vertical viewport was given unbounded height.
大致意思是在 layout 阶段父 Listview 不能判断子 Listview 的高度,这个错误可以通过设置内部的 Listview 的 shrinkWrap = true 来修正(shrinkWrap = true 代表 ListView 的高度等于它 content 的高度)。
ListView.builder(itemCount: 20,itemBuilder: (BuildContext context, int index) {return ListView.builder(shrinkWrap: true,itemCount: 5,itemBuilder: (BuildContext context, int index) {return Text("item: $index");});});
复制代码
但是这样做的话性能会比较差,因为内部的列表每次都要计算出所有 content 的高度,这个时候使用 CustomScrollView 更为合适:
CustomScrollView(slivers: <Widget>[SliverList(delegate: SliverChildBuilderDelegate((context, index) => Container(...),childCount: 50)),SliverList(delegate: SliverChildBuilderDelegate((context, index) => Container(...),childCount: 50))],);
复制代码
滑动特效
CustomScrollView 可以让它内部的 Slivers 进行联动,比如做一个可伸缩的 TitleBar 、中间区域可以固定的 header、下拉刷新组件等等。
Slivers
Flutter 提供了很多的 Sliver 组件,下面我们主要说一下它们的作用是什么:
SliverAppBar
类似于 android 中 CollapsingToolbarLayout,可以根据滑动做伸缩布局,并提供了 actions,bottom 等提高效率的属性。
SliverList / SliverGrid
用法和 ListView / GridView 基本一致。 此外,ListView = SliverList + Scrollable,也就是说 SliverList 不具备处理滑动事件的能力,所以它必须配合 CustomScrollView 来使用。
SliverFixedExtentList
它比 SliverList 多了修饰词 FixedExtent,意思是它的 item 在主轴方向上具有固定的高度/宽度。
设计它的原因是在 item 高度/宽度全都一样的场景下使用,它的效率比 SliverList 高,因为它不用通过 item 的 layout 过程就可以知道每个 item 的范围。
在使用的时候必须传入 itemExtent:
SliverFixedExtentList(itemExtent: 50.0,delegate: SliverChildBuilderDelegate(...);},),
)
复制代码
SliverPersistentHeader
SliverPersistentHeader 是一个可以固定/悬浮的 header,它可以设置在列表的任意位置,显示的内容需要设置 SliverPersistentHeaderDelegate。
SliverPersistentHeader(pinned: true,delegate: ...,
)
复制代码
SliverPersistentHeaderDelegate 是一个抽象类,我们需要自己实现它,它的实现很简单,只有四个必须要实现的成员:
class CustomDelegate extends SliverPersistentHeaderDelegate {/// 最大高度@overridedouble get maxExtent => 100;/// 最小高度@overridedouble get minExtent => 50;/// shrinkOffset: 当前 sliver 顶部越过屏幕顶部的距离/// overlapsContent: 下方是否还有 content 显示@overrideWidget build(BuildContext context, double shrinkOffset, bool overlapsContent) {return your widget);}/// 是否需要刷新@overridebool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {return maxExtent != oldDelegate.maxExtent ||minExtent != oldDelegate.minExtent;}
}
复制代码
在实际运用中沉浸式的设计是很常见的,使用 SliverPersistentHeaderDelegate 可以轻松的实现沉浸式的效果:
它的实现原理就是根据 shrinkOffset 动态调整状态栏的样式和标题栏的颜色,实现代码见下面的 沉浸式 Header。
SliverToBoxAdapter
将 BoxWidget 转变为 Sliver:由于 CustomScrollView 只能接受 Sliver 类型的 child,所以很多常用的 Widget 无法直接添加到 CustomScrollView 中,此时只需要将 Widget 用 SliverToBoxAdapter 包裹一下就可以了。 最常见的使用就是 SliverList 不支持横向模式,但是又无法直接将 ListView 直接添加到 CustomScrollView 中,此时用 SliverToBoxAdapter 包裹一下:
CustomScrollView(slivers: <Widget>[SliverToBoxAdapter(child: _buildHorizonScrollView(),),],));Widget _buildHorizonScrollView() {return Container(height: 50,child: ListView.builder(scrollDirection: Axis.horizontal,primary: false,shrinkWrap: true,itemCount: 15,itemBuilder: (context, index) {return Container(color: ColorUtils.randomColor(),width: 50,height: 50,);}),);}
复制代码
SliverPadding
可以用在 CustomScrollView 中的 Padding。 需要注意的是不要用它来包裹 SliverPersistentHeader ,因为它会使 SliverPersistentHeader 的 pinned 失效,如果 SliverPersistentHeader 非要使用 Padding 效果,可以在 delegate 内部使用 Padding。
- wrong code:
SliverPadding(padding: EdgeInsets.symmetric(horizontal: 16),sliver: SliverPersistentHeader(pinned: true,floating: false,delegate: Delegate(),),)
复制代码
- correct code:
class Delegate extends SliverPersistentHeaderDelegate {@overrideWidget build(BuildContext context, double shrinkOffset, bool overlapsContent) =>Padding(padding: EdgeInsets.symmetric(horizontal: 16),child: Container(color: Colors.yellow,),);...
}
复制代码
SliverSafeArea
用法和 SafeArea 一致。
SliverFillRemaining
可以填充屏幕剩余控件的 Sliver。
部分实例代码:
沉浸式 Header
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';class GradientSliverHeaderDelegate extends SliverPersistentHeaderDelegate {final double collapsedHeight;final double expandedHeight;final double paddingTop;final String coverImgUrl;final String title;GradientSliverHeaderDelegate({this.collapsedHeight,this.expandedHeight,this.paddingTop,this.coverImgUrl,this.title,});@overridedouble get minExtent => this.collapsedHeight + this.paddingTop;@overridedouble get maxExtent => this.expandedHeight;@overridebool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {return true;}Color makeStickyHeaderBgColor(shrinkOffset) {final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();return Color.fromARGB(alpha, 255, 255, 255);}Color makeStickyHeaderTextColor(shrinkOffset) {if (shrinkOffset <= 50) {return Colors.white;} else {final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();return Color.fromARGB(alpha, 0, 0, 0);}}Brightness getStatusBarTheme(shrinkOffset) {return shrinkOffset <= 50 ? Brightness.light : Brightness.dark;}@overrideWidget build(BuildContext context, double shrinkOffset, bool overlapsContent) {SystemUiOverlayStyle systemUiOverlayStyle = SystemUiOverlayStyle(statusBarColor: Colors.transparent,statusBarIconBrightness: getStatusBarTheme(shrinkOffset));SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);return Container(height: this.maxExtent,width: MediaQuery.of(context).size.width,child: Stack(fit: StackFit.expand,children: <Widget>[// 背景图Container(child: Image.asset(coverImgUrl,fit: BoxFit.cover,)),// 收起头部Positioned(left: 0,right: 0,top: 0,child: Container(color: this.makeStickyHeaderBgColor(shrinkOffset), // 背景颜色child: SafeArea(bottom: false,child: Container(height: this.collapsedHeight,child: Center(child: Text(this.title,style: TextStyle(fontSize: 20,fontWeight: FontWeight.w500,color: this.makeStickyHeaderTextColor(shrinkOffset), // 标题颜色),),)),),),),],),);}
}
复制代码
class CustomRefreshWidget extends SingleChildRenderObjectWidget {const CustomRefreshWidget({Key key, Widget child}): super(key: key, child: child);@overrideRenderObject createRenderObject(BuildContext context) {return SimpleRefreshSliver();}
}/// 一个简单的下拉刷新 Widget
class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter {@overridevoid performLayout() {if (child == null) {geometry = SliverGeometry.zero;return;}child.layout(constraints.asBoxConstraints(), parentUsesSize: true);double childExtent;switch (constraints.axis) {case Axis.horizontal:childExtent = child.size.width;break;case Axis.vertical:childExtent = child.size.height;break;}assert(childExtent != null);final double paintedChildSize =calculatePaintOffset(constraints, from: 0.0, to: childExtent);assert(paintedChildSize.isFinite);assert(paintedChildSize >= 0.0);final bool active = constraints.overlap < 0.0;final double overscrolledExtent =constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;double layoutExtent = child.size.height;print("overscrolledExtent:${overscrolledExtent - layoutExtent}");child.layout(constraints.asBoxConstraints(maxExtent: layoutExtent// Plus only the overscrolled portion immediately preceding this// sliver.+overscrolledExtent,),parentUsesSize: true,);if (active) {geometry = SliverGeometry(scrollExtent: layoutExtent,/// 绘制起始位置paintOrigin: min(overscrolledExtent - layoutExtent, 0),paintExtent: max(max(child.size.height, layoutExtent) ,0.0,),maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,),/// 布局占位layoutExtent: min(overscrolledExtent, layoutExtent),);} else {/// 如果不想显示可以直接设置为 zerogeometry = SliverGeometry.zero;}setChildParentData(child, constraints, geometry);}
}
复制代码
使用:
@overrideWidget build(BuildContext context) {return Scaffold(body: CustomScrollView(/// android 需要设置弹簧效果 overlap 才会起作用physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),CustomRefreshWidget(child: Container(height: 100,color: Colors.purple,child: Row(mainAxisAlignment: MainAxisAlignment.center,crossAxisAlignment: CrossAxisAlignment.center,children: <Widget>[Text("RefreshWidget",style: TextStyle(color: Colors.white),),Padding(padding: EdgeInsets.only(left: 10.0),child: CupertinoActivityIndicator(),)],),),),..._buildListView(),],));}
作者:TravelingLight_
链接:https://juejin.im/post/5eba7bd8f265da7bf32d47e5
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
这篇关于Flutter - 循序渐进 Sliver的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!