如下代碼基本參考於 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
的核心方法。返回一個![target-20180816144749.gif](https://upload-images.jianshu.io/upload_images/1877190-fb30b15d0a5403c9.gif?imageMogr2/auto-orient/strip) 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的。當高度小於時,才能切換。
監聽滑動的距離
由於要監聽CustomScrollView的滑動狀況,因此要套在它的外層。進行切換
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的界面開發有了一個相對全面的瞭解。