动画的结束状态.png
结束状态时,SectionCard
就是按照Row来排列,每一列占用了屏幕的宽度。被选中的当前SectionTitle
则是出现在被选中的SectionCard
的中间。其他的则按照一定间距排列在两边。SectionIndicator
位于SectionTitle
下面。
自定义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);
}
}
动画的初始
card
的初始状态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)
的状态。然后其他对应的进行偏移。
动画的结尾
card
的最终状态row
为前缀的变量
- 高度
就是整个的高度
- left
就是选中card的偏移量。
- 宽度
就是整个的宽度
- offset
同上。
确定中间状态
-
tColumnToRow
整体的动画,在Flutter中有很方便的lerp
函数可以确定中间的状态。只要传入我们进度的百分比就可以。这个百分比可以由滑动的过程中的offset传入。
LayoutBuilder
上一遍文章,就介绍过,使用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就可以实现了。