以下代码基本参考于 flutter_gallery中的animation_demo示例。(可以结合本文看源码)
题外话:这个demo是最炫酷的了
animation.gif
这里的动画效果我们看到:
简单的分析一下
PageView
的状态同步。CustomScrollView
.然后初始的SliveAppBar的高度应该是屏幕的高度。SliveAppBar的child是PageView
下面是一个SliveToBoxAdapter里面也放着PageView.import 'package:flutter/material.dart';
import 'package:flutter_start/demo/animation/sections.dart';
Color _kAppBackgroundColor = const Color(0xFF353662);
Duration _kScrollDuration = const Duration(milliseconds: 400);
Curve _kScrollCurve = Curves.fastOutSlowIn;
class AnimationDemoHome extends StatefulWidget {
const AnimationDemoHome({Key key}) : super(key: key);
static const String routeName = '/animation';
@override
_AnimationDemoHomeState createState() => new _AnimationDemoHomeState();
}
class _AnimationDemoHomeState extends State<AnimationDemoHome> {
@override
Widget build(BuildContext context) {
return new Scaffold(
backgroundColor: _kAppBackgroundColor,
body: new Builder(
// Insert an element so that _buildBody can find the PrimaryScrollController.
builder: _buildBody,
),
);
}
Widget _buildBody(BuildContext context) {
double height = MediaQuery.of(context).size.height;
return new SizedBox.expand(
child: new Stack(
children: <Widget>[
new CustomScrollView(
slivers: <Widget>[
SliverAppBar(
expandedHeight: height,
flexibleSpace: LayoutBuilder(builder:
(BuildContext context, BoxConstraints constraints) {
return PageView(
children: allSections.map((Section section) {
return _headerItemsFor(section);
}).toList(),
);
}),
),
SliverToBoxAdapter(
child: SizedBox(
height: 610.0,
child: PageView(
children: allSections.map((Section section) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _detailItemsFor(section).toList(),
);
}).toList(),
),
),
),
],
),
],
),
);
}
Iterable<Widget> _detailItemsFor(Section section) {
final Iterable<Widget> detailItems =
section.details.map((SectionDetail detail) {
return new SectionDetailView(detail: detail);
});
return ListTile.divideTiles(context: context, tiles: detailItems);
}
Widget _headerItemsFor(Section section) {
return SectionCard(section: section);
}
}
class SectionDetailView extends StatelessWidget {
SectionDetailView({Key key, @required this.detail})
: assert(detail != null && detail.imageAsset != null),
assert((detail.imageAsset ?? detail.title) != null),
super(key: key);
final SectionDetail detail;
@override
Widget build(BuildContext context) {
final Widget image = new DecoratedBox(
decoration: new BoxDecoration(
borderRadius: new BorderRadius.circular(6.0),
image: new DecorationImage(
image: new AssetImage(
detail.imageAsset,
package: detail.imageAssetPackage,
),
fit: BoxFit.cover,
alignment: Alignment.center,
),
),
);
Widget item;
if (detail.title == null && detail.subtitle == null) {
item = new Container(
height: 240.0,
padding: const EdgeInsets.all(16.0),
child: new SafeArea(
top: false,
bottom: false,
child: image,
),
);
} else {
item = new ListTile(
title: new Text(detail.title),
subtitle: new Text(detail.subtitle),
leading: new SizedBox(width: 32.0, height: 32.0, child: image),
);
}
return new DecoratedBox(
decoration: new BoxDecoration(color: Colors.grey.shade200),
child: item,
);
}
}
class SectionCard extends StatelessWidget {
const SectionCard({Key key, @required this.section})
: assert(section != null),
super(key: key);
final Section section;
@override
Widget build(BuildContext context) {
return new Semantics(
label: section.title,
button: true,
child: new DecoratedBox(
decoration: new BoxDecoration(
gradient: new LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[
section.leftColor,
section.rightColor,
],
),
),
child: new Image.asset(
section.backgroundAsset,
package: section.backgroundAssetPackage,
color: const Color.fromRGBO(255, 255, 255, 0.075),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
),
),
);
}
}
target-20180814110704.gif
发现我们的想法还是有一定偏差的。上面的头部部分,不只是pageView
,它需要从一个list
然后移动变成pageView
.
这个Widget
可以完全自己掌控布局的排列。我们需要做的是将它的自组件都传递给他,然后实现它的方法,就可以完全的掌握自己的布局了。
使用它有两个关键点:
MultiChildLayoutDelegate
来自己实现布局按照这个思路,我们希望每一个Page
都是能实现这个样的动画效果,所以我们自己定义CustomMultiChildLayout
作为PageView的child。
同时,我们还需要将之前的4个SectionsCard
用LayoutId包裹后,传入其中。
MultiChildLayoutDelegate
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);
}
}
整个动画效果就是,从竖排的4列,变化成横排的4列。
为每个card
定义好
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
为前缀的变量
tColumnToRow
整体的动画,在Flutter中有很方便的lerp
函数可以确定中间的状态。只要传入我们进度的百分比就可以。这个百分比可以由滑动的过程中的offset传入。//只显示sliverAppBar部分
slivers: <Widget>[
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
return _handleScrollNotification(
notification, appBarMidScrollOffset);
},
child: SliverAppBar(
backgroundColor: _kAppBackgroundColor,
expandedHeight: height - statusHeight,
bottom: PreferredSize(
preferredSize:
const Size.fromHeight(_kAppBarMinHeight - kToolbarHeight),
child: Container(width: 0.0, height: 0.0),
),
pinned: true,
//同样根据上一节我们学习到的内容,我们可以通过layoutbuilder来获取变化的约束
flexibleSpace: LayoutBuilder(builder:
(BuildContext context, BoxConstraints constraints) {
//因为发现当滚动成column时,上面有statusBar高度的padding,当变成row时,整个padding就变成0,所以这里是这个的变化值
double t =
1.0 - (height - constraints.maxHeight) / (height * 0.3);
final Curve statusBarHeightCurve =
Interval(0.0, 1.0, curve: Curves.fastOutSlowIn);
double extraPaddingTop =
statusHeight * statusBarHeightCurve.transform(t.clamp(0.0, 1.0));
//这里开始计算 tColumnToRow的比例。其实就是滚动的距离。
final Size size = constraints.biggest;
final double tColumnToRow = 1.0 -
((size.height - _kAppBarMidHeight) /
(height - statusHeight - _kAppBarMidHeight))
.clamp(0.0, 1.0);
final List<Widget> sectionCards = <Widget>[];
for (int index = 0; index < allSections.length; index++) {
Section section = allSections[index];
sectionCards.add(_headerItemsFor(section));
}
List<Widget> children = [];
for (int index = 0; index < sectionCards.length; index++) {
//这里一定要注意, CustomMultiChildLayout中的,子节点,都必须用LayoutId来包裹!!!
children.add(new LayoutId(
id: 'card$index',
child: sectionCards[index],
));
}
List<Widget> layoutChildren = [];
print('selectedIndex.value=${selectedIndex.value}');
for (int index = 0; index < sectionCards.length; index++) {
layoutChildren.add(new CustomMultiChildLayout(
delegate: _AllSectionsLayout(
tColumnToRow: tColumnToRow,
translation: new Alignment(
(selectedIndex.value - index) * 2.0 - 1.0, -1.0),
selectedIndex: selectedIndex.value
),
children: children,
));
}
//将上面的用PageView再包裹一次。
return Padding(
padding: EdgeInsets.only(top: extraPaddingTop),
child: PageView(
physics: _headingScrollPhysics,
controller: _headingPageController,
children: layoutChildren,
),
),
);
}),
),
),
上面这段代码,有下面几个重点
tColumnToRow
这个值也是根据我们滑动的整体状态来计算的。然后,我还要处理两个细节。
//省略代码...
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
return _handlePageNotification(notification,
_headingPageController, _detailsPageController);
},
child: Padding(
padding: EdgeInsets.only(top: extraPaddingTop),
child: PageView(
physics: _headingScrollPhysics,
controller: _headingPageController,
children: layoutChildren,
),
),
);
physics
改为NeverScrollableScrollPhysics
。它将会导致页面不能滚动。
反之,就设置为PageScrollPhysics()
.像页面一样滚动。 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;
}
这个效果是整个SliverAppBar来提供的。所以设置他的physics
。
当滚动的距离大于一办时,判断对应的滚动反向,来创造对应simulation
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) {
final double velocity = math.max(dragVelocity, minFlingVelocity);
return new ScrollSpringSimulation(spring, offset, midScrollOffset, velocity,
tolerance: tolerance);
}
Simulation _toZeroScrollOffsetSimulation(double offset, double dragVelocity) {
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) {
// The drag ended with sufficient velocity to trigger creating a simulation.
// If the simulation is headed up towards midScrollOffset but will not reach it,
// then snap it there. Similarly if the simulation is headed down past
// midScrollOffset but will not reach zero, then snap it to zero.
final double simulationEnd = simulation.x(double.infinity);
if (simulationEnd >= midScrollOffset) return simulation;
if (dragVelocity > 0.0)
return _toMidScrollOffsetSimulation(offset, dragVelocity);
if (dragVelocity < 0.0)
return _toZeroScrollOffsetSimulation(offset, dragVelocity);
} else {
// The user ended the drag with little or no velocity. If they
// didn't leave the offset above midScrollOffset, then
// snap to midScrollOffset if they're more than halfway there,
// otherwise snap to zero.
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;
}
}
运行效果
target-20180814215213.gif
这样,我们就做成很接近最后效果的动画了。要实现最后的动画,只要用相同的办法去创建title
和indicator
就行了。
虽然我们的代码,和animation_demo源码中的代码有所不一样。但是核心是一样的。 这边文章我们熟悉了
CustomScrollView
的MultiChildLayoutDelegate
通过CustomScrollView
的MultiChildLayoutDelegate
的performLayout
方法的实现,来完成自定义的多组件之间的布局。
自定义动画的过程,在Flutter中其实相对简单。提供了很多帮助的计算方式。需要做的是确定要初始值,和最终值,中间的过度变量可以考虑使用lerp
就可以完成。
之前的文章,我们分析过Flutter中数据的传递。需要监听发送的ScrollEvent,我们只要在我们监听的Widget的外层,套一层NotificationListener进行监听就好
我们更加熟悉了ScrollView的两个要素。controller
和physics
。
controller
我们可以得到滚动的状态,和控制滚动的情况。physics
滚动的效果。我们可以添加NeverScrollableScrollPhysics
。这样就不滚动了。添加PageScrolPhysics
,这样就是按照页面滚动。添加BounceScrollPhysics
,就实现ios中的弹性滚动了。好的。这边文章,我们就暂时到这里。 下一遍文章,我们先介绍一个Flutter中整体的视图树,然后回顾一下我们遇到过的组件。