如下代码基本参考于 flutter_gallery中的animation_demo示例。(能够结合本文看源码)html
总体动画效果预览 git
源码中经过自定义的一个RenderObjectWidget
和自定义RenderSliver
来实现的。 下面咱们就来了解一下RenderObjectWidget
和RenderSliver
。github
为RenderObjectElement提供配置参数。RenderObjectElement
则是包装了提供一个真正为应用提供渲染的RenderObject。算法
当只有一个child的时,就可使用这个RenderObjectWidget
,它已经为咱们实现好了RenderObjectElement
,咱们只要实现RenderObject
的增删改的操做就能够了。 因此实现的核心仍是在RenderObject
上。spring
RenderSliver是继承于RenderObject。 RenderObject能够简单的理解成Flutter中的dom模型,主要是负责布局和绘制的。能够继承他实现本身的布局协议。 Flutter中内置实现了两种布局协议。segmentfault
咱们以前使用的非滚动的布局,好比说Column
、Row
之类的,都是基于这种布局协议。他提供一个笛卡尔的坐标系的约束。markdown
BoxContrains
,计算出对应的Size
。Viewport
的概念。 viewport就至关于一个窗口。窗口内有许多的sliver.他们能够滚动。滚动时,随着他们距离窗口顶部位置(前沿的变化),因此他们的在窗口内的可见部分多是变化的。SliverConstraints
,计算出对应的SliverGeometry 。SliverGeometry
中也有一个很重要的参数是 SliverGeometry.paintExtent ,用来描述沿着主轴绘制的范围。 最终的可见区域就是 在viewport中范围和主轴绘制范围的交集。接着再回头代码app
//如上面的所诉,咱们知道这个`SingleChildRenderObjectWidget`中所作的事情,就是建立返回咱们的RenderObject
class _StatusBarPaddingSliver extends SingleChildRenderObjectWidget {
const _StatusBarPaddingSliver({
Key key,
@required this.maxHeight,
this.scrollFactor: 5.0,
}) : assert(maxHeight != null && maxHeight >= 0.0),
assert(scrollFactor != null && scrollFactor >= 1.0),
super(key: key);
//咱们本身定义的变量。最大高度和滚动的因子
final double maxHeight;
final double scrollFactor;
//建立createRenderObject
@override
_RenderStatusBarPaddingSliver createRenderObject(BuildContext context) {
return new _RenderStatusBarPaddingSliver(
maxHeight: maxHeight,
scrollFactor: scrollFactor,
);
}
//更新RenderObject
@override
void updateRenderObject(BuildContext context, _RenderStatusBarPaddingSliver renderObject) {
//这里就是级联的语法,改变状态
renderObject
..maxHeight = maxHeight
..scrollFactor = scrollFactor;
}
//这里是由于了debug模式下,能看到属性,因此写的方法
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DoubleProperty('maxHeight', maxHeight));
description.add(new DoubleProperty('scrollFactor', scrollFactor));
}
}
复制代码
看到自定义实现的SingleChildRenderObjectWidget
,其实很简单,就是实现建立和更新RenderObject
的代码就能够了。真正的逻辑在RenderObject
中。框架
//继承至`RenderSliver`
class _RenderStatusBarPaddingSliver extends RenderSliver {
_RenderStatusBarPaddingSliver({
@required double maxHeight,
@required double scrollFactor,
}) : assert(maxHeight != null && maxHeight >= 0.0),
assert(scrollFactor != null && scrollFactor >= 1.0),
_maxHeight = maxHeight,
_scrollFactor = scrollFactor;
//提供get 和set方法。set方法每次更新时,若是值发生变化了。就须要调用markNeedsLayout,使其从新布局
// The height of the status bar
double get maxHeight => _maxHeight;
double _maxHeight;
set maxHeight(double value) {
assert(maxHeight != null && maxHeight >= 0.0);
if (_maxHeight == value)
return;
_maxHeight = value;
markNeedsLayout();
}
// That rate at which this renderer's height shrinks when the scroll
// offset changes.
double get scrollFactor => _scrollFactor;
double _scrollFactor;
set scrollFactor(double value) {
assert(scrollFactor != null && scrollFactor >= 1.0);
if (_scrollFactor == value)
return;
_scrollFactor = value;
markNeedsLayout();
}
//performLayout 是核心方法。返回一个SliverGeometry来描述这个时候的sliver的大小
@override
void performLayout() {
final double height = (maxHeight - constraints.scrollOffset / scrollFactor).clamp(0.0, maxHeight);
geometry = new SliverGeometry(
//paintExtent,即是当前绘制的高度。
paintExtent: math.min(height, constraints.remainingPaintExtent),
scrollExtent: maxHeight,
maxPaintExtent: maxHeight,
);
}
}
复制代码
RenderSliver
的核心方法。返回一个 SliverGeometry
来描述这个时候的sliver的大小。这样,放到CustomScrollView内,就能够感知到约束,进而完成效果了。dom
能够看到这里的头部滚动是使用SliverPersistentHeader
来实现的。而咱们以前的头部滚动都是用SliverAppBar
来作的。
经过跟踪源码,咱们发现SliverAppBar
其实返回的就是SliverPersistentHeader
。
会随着sliver滚动到viewport的前缘的距离变化,尺寸随着变化。 它的总体配置,主要仍是经过内部的SliverPersistentHeaderDelegate
来进行管理。
这个类中,主要是重写一下方法
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
复制代码
主要是建立放置在SliverPersistentHeader内的组件。
context
是sliver的BuildContext
shrinkOffset
是从maxExtent
到minExtent
的距离, 表示Sliver当前收缩的偏移量。当shrinkOffset
为零时,将在主轴中以maxExtent
展示(就是彻底展开)。当shrinkOffset
等于maxExtent
和minExtent
(正数)之间的差别时,将在主轴中使用minExtent
范围呈现内容(最小状态)。该 shrinkOffset
会一直在这个范围内的正数。overlapsContent
若是以后有sliver(若是有的话)将在它下面呈现,则该参数为true。若是他下面没有任何内容则为false。一般,这用于决定是否绘制阴影以模拟位于其下方内容之上的内容。一般状况下,若是shrinkOffset
处于最大值则为true,不然为false,但这不能保证。有关能够与其无关的值 的示例,请参阅NestedScrollView。overlapsContent``shrinkOffset
double get minExtent;
double get maxExtent;
复制代码
FloatingHeaderSnapConfiguration get snapConfiguration => null;
复制代码
当SliverPersistentHeader.floating被设置为true,用他能够管理浮动进去的动画效果。这里咱们这个头部不是浮动的,因此能够无论。
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);
复制代码
判断两个方法是否不一样,若是不一样的话,就会重现去建立。
SliverAppBar
的。 同时,总体的形状变化,咱们不须要其余的效果,只要保持和外部滚动的大小一致就能够了。 咱们不使用SliverAppBar
。本身简单的来实现一个SliverPersistentHeaderDelegate
。//自定义的_SliverAppBarDelegate ,必须输入最小和最大高度
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate({
@required this.minHeight,
@required this.maxHeight,
@required this.child,
});
final double minHeight;
final double maxHeight;
final Widget child;
@override double get minExtent => minHeight;
@override double get maxExtent => math.max(maxHeight, minHeight);
//按照分析,让子组件尽量占用布局就OK
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return new SizedBox.expand(child: child);
}
//若是传递的这几个参数变化了,那就重写建立
@override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return maxHeight != oldDelegate.maxHeight
|| minHeight != oldDelegate.minHeight
|| child != oldDelegate.child;
}
@override
String toString() => '_SliverAppBarDelegate';
}
复制代码
SizeBox
也是一个RenderObject
,并且和上面同样,是SingleChildRenderObjectWidget
。SizeBox.expand
的方法,就是提供一个尽量大的组件。pinned
为true
由于咱们的头部是最后仍是粘性在上面的,因此设置SliverPersistentHeader的pined为true这个Widget能够彻底本身掌控布局的排列。咱们须要作的是将它的自组件都传递给他,而后实现它的方法,就能够彻底的掌握本身的布局了。 彻底符合咱们的需求。
咱们在这个组件中要安排动画包括 4组SectionCard
、SectionTitle
、SectionIndicator
。
开始状态
SectionCard
就是按照column来排列,平均分配屏幕的高度。SectionTitle
则是出如今每一个SectionCard
的中间。SectionIndicator
位于右下角。 结束状态
SectionCard
就是按照Row来排列,每一列占用了屏幕的宽度。被选中的当前SectionTitle
则是出如今被选中的SectionCard
的中间。其余的则按照必定间距排列在两边。SectionIndicator
位于SectionTitle
下面。 class _AllSectionsLayout extends MultiChildLayoutDelegate {
int cardCount = 4;
double selectedIndex = 0.0;
double tColumnToRow = 0.0;
///Alignment(-1.0, -1.0) 表示矩形的左上角。
///Alignment(1.0, 1.0) 表明矩形的右下角。
Alignment translation = new Alignment(0 * 2.0 - 1.0, -1.0);
_AllSectionsLayout({this.tColumnToRow,this.selectedIndex,this.translation});
@override
void performLayout(Size size) {
//初始值
//竖向布局时
//卡片的left
final double columnCardX = size.width / 5.0;
//卡片的宽度Width
final double columnCardWidth = size.width - columnCardX;
//卡片的高度
final double columnCardHeight = size.height / cardCount;
//横向布局时
final double rowCardWidth = size.width;
final Offset offset = translation.alongSize(size);
double columnCardY = 0.0;
double rowCardX = -(selectedIndex * rowCardWidth);
for (int index = 0; index < cardCount; index++) {
// Layout the card for index.
final Rect columnCardRect = new Rect.fromLTWH(
columnCardX, columnCardY, columnCardWidth, columnCardHeight);
final Rect rowCardRect =
new Rect.fromLTWH(rowCardX, 0.0, rowCardWidth, size.height);
// 定义好初始的位置和结束的位置,就可使用这个lerp函数,轻松的找到中间状态值
//rect 的 shift ,至关于 offset的translate
final Rect cardRect =
_interpolateRect(columnCardRect, rowCardRect).shift(offset);
final String cardId = 'card$index';
if (hasChild(cardId)) {
layoutChild(cardId, new BoxConstraints.tight(cardRect.size));
positionChild(cardId, cardRect.topLeft);
}
columnCardY += columnCardHeight;
rowCardX += rowCardWidth;
}
}
@override
bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) {
print('oldDelegate=$oldDelegate');
return false;
}
Rect _interpolateRect(Rect begin, Rect end) {
return Rect.lerp(begin, end, tColumnToRow);
}
Offset _interpolatePoint(Offset begin, Offset end) {
return Offset.lerp(begin, end, tColumnToRow);
}
}
复制代码
card
的初始状态column
为前缀的变量。
Aligment.alongSize
来进行转换。Alignment(-1.0, -1.0)
就表明左上角。Alignment(1.0, 1.0)
表明矩形的右下角。整个Aligment
至关于一个边长为2,中心点在原点的正方形。 须要让index== selectedIndex的card的Aligment为左上角Alignment(1.0, 1.0)
的状态。而后其余对应的进行偏移。card
的最终状态row
为前缀的变量
高度 就是整个的高度
left 就是选中card的偏移量。
宽度 就是整个的宽度
offset 同上。
tColumnToRow
总体的动画,在Flutter中有很方便的lerp
函数能够肯定中间的状态。只要传入咱们进度的百分比就能够。这个百分比能够由滑动的过程当中的offset传入。上一遍文章,就介绍过,使用LayoutBuilder能够获得变化的约束。来构建动画效果。这里也同样。根据滑动时,变化的约束,来计算百分比。来肯定中间状态。
这里的AnimatedWidget
会在后面介绍
class _AllSectionsView extends AnimatedWidget {
_AllSectionsView({
Key key,
this.sectionIndex,
@required this.sections,
@required this.selectedIndex,
this.minHeight,
this.midHeight,
this.maxHeight,
this.sectionCards: const <Widget>[],
}) : assert(sections != null),
assert(sectionCards != null),
assert(sectionCards.length == sections.length),
assert(sectionIndex >= 0 && sectionIndex < sections.length),
assert(selectedIndex != null),
assert(selectedIndex.value >= 0.0 && selectedIndex.value < sections.length.toDouble()),
super(key: key, listenable: selectedIndex);
final int sectionIndex;
final List<Section> sections;
final ValueNotifier<double> selectedIndex;
final double minHeight;
final double midHeight;
final double maxHeight;
final List<Widget> sectionCards;
double _selectedIndexDelta(int index) {
return (index.toDouble() - selectedIndex.value).abs().clamp(0.0, 1.0);
}
Widget _build(BuildContext context, BoxConstraints constraints) {
final Size size = constraints.biggest;
// 计算中间状态。实际上是最大值,到中间值的范围
final double tColumnToRow =
1.0 - ((size.height - midHeight) /
(maxHeight - midHeight)).clamp(0.0, 1.0);
//中间值到最小值的方法,这个阶段,只会轻微的上移动
final double tCollapsed =
1.0 - ((size.height - minHeight) /
(midHeight - minHeight)).clamp(0.0, 1.0);
//indicator的透明度须要根据移动尺寸来变化
double _indicatorOpacity(int index) {
return 1.0 - _selectedIndexDelta(index) * 0.5;
}
//title的透明度须要根据移动尺寸来变化
double _titleOpacity(int index) {
return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.5;
}
//title的Scale须要根据移动尺寸来变化
double _titleScale(int index) {
return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.15;
}
final List<Widget> children = new List<Widget>.from(sectionCards);
for (int index = 0; index < sections.length; index++) {
final Section section = sections[index];
//记住,每一个child都必需要有位置的LayoutId,方便上面再delegate中识别操做!!
children.add(new LayoutId(
id: 'title$index',
child: new SectionTitle(
section: section,
scale: _titleScale(index),
opacity: _titleOpacity(index),
),
));
}
for (int index = 0; index < sections.length; index++) {
//记住,每一个child都必需要有位置的LayoutId,方便上面再delegate中识别操做!!
children.add(new LayoutId(
id: 'indicator$index',
child: new SectionIndicator(
opacity: _indicatorOpacity(index),
),
));
}
return new CustomMultiChildLayout(
delegate: new _AllSectionsLayout(
translation: new Alignment((selectedIndex.value - sectionIndex) * 2.0 - 1.0, -1.0),
tColumnToRow: tColumnToRow,
tCollapsed: tCollapsed,
cardCount: sections.length,
selectedIndex: selectedIndex.value,
),
children: children,
);
}
@override
Widget build(BuildContext context) {
//经过LayoutBuilder来传递当前正确的约束
return new LayoutBuilder(builder: _build);
}
}
复制代码
头部和下面的部分,都使用Flutter自带提供的PageView就能够实现了。
能够看到不管是上面的PageView
仍是下面的PageView
须要作到状态同步。 同时,单页内滑动效果,也须要肯定当前选中的那个位置。
Flutter中滑动的组件,都会发送出本身的Notification。以前的文章介绍过,只要在要监听的组件外面套一层NotificationListener就能够监听到对应的事件。
能够滚动的部件,基本都有一个ScrollController来控制和查询滑动的状态。 监听的滑动事件过程当中,咱们能够经过它来完成两个类的状态同步。
AnimatedWidget
实际上是一个帮助类。咱们能够给他咱们能够监听的属性。(动画或者ValueNotifier/ChangeNotifier),每当监听的属性发送通知时,都会自动调用setState
的方法进行rebuild
。 使用它,就避免了本身手动写注册监听的事件。同时,当他改变后,咱们须要监听的Widget,重写setState进行rebuild。 咱们使用,就能够避免本身手动实现生命周期的监听和取消监听这样的模板化的代码了。
//_AnimationDemoHomeState文件中
final PageController _headingPageController = new PageController();
final PageController _detailsPageController = new PageController();
ValueNotifier<double> selectedIndex = new ValueNotifier<double>(0.0);
复制代码
监听事件 在每一个PageView的外层套用NotificationListener来监听事件。以前介绍过。这是常规操做。
处理Notification监听事件 就是监听事件,而后触发ValueNotifier的监听事件,和使用controller同步上下滚动的状态。
bool _handlePageNotification(ScrollNotification notification, PageController leader, PageController follower) {
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
//修改selectedIndex 会触发监听
selectedIndex.value = leader.page;
if (follower.page != leader.page)
//若是两个Page不想都能,就让follower的一方,滚动过去
follower.position.jumpToWithoutSettling(leader.position.pixels); // ignore: deprecated_member_use
}
return false;
}
复制代码
AnimatedWidget
的功能。由于咱们的头部几个组件,也须要这里同步 状态。因此让_AllSectionsView
继承它。这样,就避免写重复的注册监听这个时间的模板化代码(在生命周期里,initState
.didChangeDependes
注册这个监听,在dispose内,取消这个监听。) 这样上面一改变这个ValueNotifier的值,就会直接出发_AllSectionsView
rebuild。来完成动画效果。这些滚动组件的物理滚动效果都是经过ScrollPhysics
来进行配置的。 ####Flutter自带的 自动的ScrollPhysics
就有4个。
BouncingScrollPhysics
,弹性的滚动效果。ClampingScrollPhysics
,正常的滚动效果,没有弹性。NeverScrollableScrollPhysics
,不滚动。AlwaysScrollableScrollPhysics
,在Android上和ClampingScrollPhysics
同样,在IOS上和BouncingScrollPhysics
同样。这个动画中,有两种处理。
由于上下都是PageView,当单页内的动画在初始状态和结束状态(中间)中间。是不能切换PageView的。当高度小于时,才能切换。
监听滑动的距离
进行切换
bool _handleScrollNotification(ScrollNotification notification, double midScrollOffset) {
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
//这里就是切换的代码了。超过中间的高度,则开始滚动,复制不能滚动。
final ScrollPhysics physics = _scrollController.position.pixels >= midScrollOffset
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics();
if (physics != _headingScrollPhysics) {
setState(() {
_headingScrollPhysics = physics;
});
}
}
return false;
}
复制代码
CustomScrollView滑动时,当方向是朝着上,并且放手时,会自动吸附到中间位置。 吸附的动画效果,自己没有提供。因此咱们须要本身重写。
能够理解成动画进行的函数。
BouncingScrollSimulationBounce弹性的滚动模拟
FrictionSimulation摩擦参数的的滚动模拟
GravitySimulation相似重力的模
SpringSimulation弹簧弹力的模拟。
咱们这里,经过自定义ScrollPhysics 返回对应的SpringSimulation
就知足咱们的效果了。
class _SnappingScrollPhysics extends ClampingScrollPhysics {
const _SnappingScrollPhysics({
ScrollPhysics parent,
@required this.midScrollOffset,
}) : assert(midScrollOffset != null),
super(parent: parent);
//中间的偏移量。用于区分
final double midScrollOffset;
@override
_SnappingScrollPhysics applyTo(ScrollPhysics ancestor) {
return new _SnappingScrollPhysics(parent: buildParent(ancestor), midScrollOffset: midScrollOffset);
}
//粘性到中间的移动
Simulation _toMidScrollOffsetSimulation(double offset, double dragVelocity) {
//去到滑动的速度和默认最小Fling速度的最大值
final double velocity = math.max(dragVelocity, minFlingVelocity);
//建立ScrollSpringSimulation。
return new ScrollSpringSimulation(spring, offset, midScrollOffset, velocity, tolerance: tolerance);
}
//粘性到原点的移动
Simulation _toZeroScrollOffsetSimulation(double offset, double dragVelocity) {
//去到滑动的速度和默认最小Fling速度的最大值
final double velocity = math.max(dragVelocity, minFlingVelocity);
return new ScrollSpringSimulation(spring, offset, 0.0, velocity, tolerance: tolerance);
}
@override
Simulation createBallisticSimulation(ScrollMetrics position, double dragVelocity) {
//获得父类的模拟,咱们再修改
final Simulation simulation = super.createBallisticSimulation(position, dragVelocity);
//获得当前的偏移
final double offset = position.pixels;
if (simulation != null) {
//经过这方法,能够快速拿到终止的位置
final double simulationEnd = simulation.x(double.infinity);
//当终止的位置大于midScrollOffset时,能够不进行处理,正常滑动
if (simulationEnd >= midScrollOffset)
return simulation;
//当小于mid,并且速度方向向上的话,就粘性到中间
if (dragVelocity > 0.0)
return _toMidScrollOffsetSimulation(offset, dragVelocity);
//当小于mid,并且速度方向向下的话,就粘性到底部
if (dragVelocity < 0.0)
return _toZeroScrollOffsetSimulation(offset, dragVelocity);
} else {
//若是中止时,没有触发任何滑动效果,那么,当滑动在上部时,并且接近mid,就会粘性到mid
final double snapThreshold = midScrollOffset / 2.0;
if (offset >= snapThreshold && offset < midScrollOffset)
return _toMidScrollOffsetSimulation(offset, dragVelocity);
//若是滑动在上部,并且贴近底部的话,就粘性到底部。
if (offset > 0.0 && offset < snapThreshold)
return _toZeroScrollOffsetSimulation(offset, dragVelocity);
}
return simulation;
}
}
复制代码
经过解析,咱们除了明白复杂的动画效果,咱们如何进行自定义外,咱们能够有两个基础的概念
Scrollable的部件,滚动效果由physic
配置,滚动控制由controller
配置。
这边文章经过自定义的SingleChildRenderObjectWidget,返回自定义的RenderObject。来彻底控制咱们的组件的布局也能够看出。
RenderObjectWidget RenderObjectWidget内主要负责对RenderObject的配置。配置了他的更新规则和建立规则。
RenderObject 而RenderObject则进行真实的布局和绘制。真实的 布局代码是在它内完成的。 而flutter内置的协议RenderSliver则是在performLayout方法中,经过SliverContraints
这种约束,来肯定返回SliverGeometry
就能够了。
RenderObjectElement 这里没有看到的是这个类,他主要进行dom的diff算法。由于咱们继承的SingleChildRenderObjectWidget
已经为咱们建立好了对应的SingleChildRenderObjectElement
了。 它内负责的就是真实的增删改的代码。
同时,咱们也能够进一步了解下面张图的意思
介绍到这边文章,咱们已经大致对Flutter的界面开发有了一个相对全面的了解。