以下代碼基本參考于 flutter_gallery中的animation_demo示例。(可以結合本文看源碼)
題外話:這個demo是最炫酷的了
這里的動畫效果我們看到:
- 有一個多頁的滾動
- 滑到上下滑到將近一半瞪醋,會有一個粘性效果本砰,吸附到一半刨晴。再往上薇芝,就正撑恚滑動务傲。
3.一半往上,下面的白色標簽開始發(fā)生位移枣申。一半往下售葡,整個4個卡片發(fā)生位移。
簡單的分析一下
上下滾動忠藤,并且自定義動畫效果挟伙。嗯。上一遍文章的CustomScrollView
左右滾動模孩,切換頁面尖阔。嗯。PageView榨咐。
PageView可以讓像是一頁一頁滑動介却。而且每個頁面的大小是一樣的。
使用PageController
來進行控制块茁。上下要同時切換筷笨。肯定也需要上下兩個
PageView
的狀態(tài)同步龟劲。
第一次接觸
先準備好數據胃夏。查看sections.dart〔可以不管仰禀,先復制過來。
初始化布局蚕愤。
像是大體想象的框架應該是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,
),
),
);
}
}
- 效果
發(fā)現我們的想法還是有一定偏差的萍诱。上面的頭部部分悬嗓,不只是pageView
,它需要從一個list
然后移動變成pageView
.
CustomMultiChildLayout
這個Widget
可以完全自己掌控布局的排列。我們需要做的是將它的自組件都傳遞給他裕坊,然后實現它的方法包竹,就可以完全的掌握自己的布局了。
使用它有兩個關鍵點:
- 自定義
MultiChildLayoutDelegate
來自己實現布局 - 他的每個child都需要用layoutId來包裹,并且分配給他們的id周瞎,都必須是唯一的苗缩。
按照這個思路,我們希望每一個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函數,輕松的找到中間狀態(tài)值
//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
的初始狀態(tài)column
為前綴的變量榛泛。
- 高度
就是按照我們看到的,豎排的情況下噩斟,每個Card的高度是整個appBar高度的4分之一曹锨。 - left
統一的位置。 - 寬度
去掉left部分的剃允,寬度 - Offset
Offset需要確定的位置沛简,需要和選定的坐標協同。選定的Index斥废,畢竟出現在當前位置椒楣。就是他的Offset的x,必須和自己的left相反牡肉,這樣才能在第一個位置捧灰。
它是用Aligment.alongSize
來進行轉換。Alignment(-1.0, -1.0)
就代表左上角统锤。Alignment(1.0, 1.0)
代表矩形的右下角毛俏。整個Aligment
相當于一個邊長為2,中心點在原點的正方形饲窿。
需要讓index== selectedIndex的card的Aligment為左上角Alignment(1.0, 1.0)
的狀態(tài)煌寇。然后其他對應的進行偏移。
動畫的結尾
card
的最終狀態(tài)row
為前綴的變量
高度
就是整個的高度left
就是選中card的偏移量逾雄。寬度
就是整個的寬度offset
同上阀溶。
確定中間狀態(tài)
-
tColumnToRow
整體的動畫腻脏,在Flutter中有很方便的lerp
函數可以確定中間的狀態(tài)。只要傳入我們進度的百分比就可以淌哟。這個百分比可以由滑動的過程中的offset傳入迹卢。
SliverAppBar
//只顯示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,
//同樣根據上一節(jié)我們學習到的內容辽故,我們可以通過layoutbuilder來獲取變化的約束
flexibleSpace: LayoutBuilder(builder:
(BuildContext context, BoxConstraints constraints) {
//因為發(fā)現當滾動成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中的殃饿,子節(jié)點,都必須用LayoutId來包裹S蟪Α:醴肌!
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,
),
),
);
}),
),
),
上面這段代碼奈惑,有下面幾個重點
SliverAppBar的bottom
因為我們使用Pinned屬性。這個屬性會懸浮我們的AppBar在頂部睡汹。但是如果默認情況下肴甸,這時appBar的高度就是有56邏輯像素這樣。所以囚巴,我們需要添加一個bottom原在,讓它,增加到我們想要的最后高度彤叉。調整整體的padding
從動畫效果可以看到庶柿,padding有一個從有到無的狀態(tài),當從column變成row的過程中秽浇,所以我們要對其進行計算浮庐。計算
tColumnToRow
這個值也是根據我們滑動的整體狀態(tài)來計算的。LayoutId
這個一定要記准婧恰兔辅!
CustomMultiChildLayout中的,子節(jié)點击喂,都必須用LayoutId來包裹NΑ!懂昂!
然后介时,我還要處理兩個細節(jié)。
一個是當滾動到中間位置后,就不能左右切換了沸柔。
- 監(jiān)聽
將NotificationListener包裹在pageView之外循衰,就可以監(jiān)聽PageView的滾動事件了。
//省略代碼...
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,
),
),
);
- 切換
這個需要監(jiān)聽褐澎,滾動的事件会钝,當滾動的距離得到一般之后,就將PageView的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
掸读。
當滾動的距離大于一辦時串远,判斷對應的滾動反向,來創(chuàng)造對應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;
}
}
運行效果
這樣儿惫,我們就做成很接近最后效果的動畫了澡罚。要實現最后的動畫,只要用相同的辦法去創(chuàng)建title
和indicator
就行了姥闪。
總結
雖然我們的代碼始苇,和animation_demo源碼中的代碼有所不一樣。但是核心是一樣的筐喳。
這邊文章我們熟悉了
CustomScrollView
的MultiChildLayoutDelegate
通過CustomScrollView
的MultiChildLayoutDelegate
的performLayout
方法的實現催式,來完成自定義的多組件之間的布局。
自定義動畫的過程
自定義動畫的過程避归,在Flutter中其實相對簡單荣月。提供了很多幫助的計算方式。需要做的是確定要初始值梳毙,和最終值哺窄,中間的過度變量可以考慮使用lerp
就可以完成。
監(jiān)聽事件
之前的文章账锹,我們分析過Flutter中數據的傳遞萌业。需要監(jiān)聽發(fā)送的ScrollEvent,我們只要在我們監(jiān)聽的Widget的外層奸柬,套一層NotificationListener進行監(jiān)聽就好
ScrollView的要素
我們更加熟悉了ScrollView的兩個要素生年。controller
和physics
。
-
controller
我們可以得到滾動的狀態(tài)廓奕,和控制滾動的情況抱婉。 -
physics
滾動的效果档叔。我們可以添加NeverScrollableScrollPhysics
。這樣就不滾動了蒸绩。添加PageScrolPhysics
衙四,這樣就是按照頁面滾動。添加BounceScrollPhysics
,就實現ios中的彈性滾動了患亿。
好的传蹈。這邊文章,我們就暫時到這里窍育。
下一遍文章卡睦,我們先介紹一個Flutter中整體的視圖樹,然后回顧一下我們遇到過的組件宴胧。