如何为 SliverAppBar 中项目的位置设置动画以在关闭时围绕标题移动它们
How to animate the position of the items in a SliverAppBar to move them around the title when closed
我对 Appbar 有这些要求,但我找不到解决它们的方法。
- 当拉伸时,AppBar 必须显示两个图像一个在另一个上面并且标题必须隐藏。
- 当关闭时,AppBar必须显示标题并且滚动时必须缩小两个图像并移动到标题的两侧.滚动时标题可见。
我创建了几个 mock-ups 来帮助获得所需的结果。
这是拉伸后的应用栏:
这是关闭时的应用栏:
您可以通过扩展 SliverPersistentHeaderDelegate
创建自己的 SliverAppBar
。
平移、缩放和不透明度更改将在 build(...)
方法中完成,因为这将在范围更改(通过滚动)期间调用,minExtent <-> maxExtent
。
这是示例代码。
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primaryColor: Colors.blue,
),
home: HomePage(),
);
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
delegate: MySliverAppBar(
title: 'Sample',
minWidth: 50,
minHeight: 25,
leftMaxWidth: 200,
leftMaxHeight: 100,
rightMaxWidth: 100,
rightMaxHeight: 50,
shrinkedTopPos: 10,
),
pinned: true,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, int i) => Container(
height: 50,
color: Color.fromARGB(
255,
Random().nextInt(255),
Random().nextInt(255),
Random().nextInt(255),
),
),
childCount: 50,
),
),
],
),
);
}
}
class MySliverAppBar extends SliverPersistentHeaderDelegate {
MySliverAppBar({
required this.title,
required this.minWidth,
required this.minHeight,
required this.leftMaxWidth,
required this.leftMaxHeight,
required this.rightMaxWidth,
required this.rightMaxHeight,
this.titleStyle = const TextStyle(fontSize: 26),
this.shrinkedTopPos = 0,
});
final String title;
final TextStyle titleStyle;
final double minWidth;
final double minHeight;
final double leftMaxWidth;
final double leftMaxHeight;
final double rightMaxWidth;
final double rightMaxHeight;
final double shrinkedTopPos;
final GlobalKey _titleKey = GlobalKey();
double? _topPadding;
double? _centerX;
Size? _titleSize;
double get _shrinkedTopPos => _topPadding! + shrinkedTopPos;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
if (_topPadding == null) {
_topPadding = MediaQuery.of(context).padding.top;
}
if (_centerX == null) {
_centerX = MediaQuery.of(context).size.width / 2;
}
if (_titleSize == null) {
_titleSize = _calculateTitleSize(title, titleStyle);
}
double percent = shrinkOffset / (maxExtent - minExtent);
percent = percent > 1 ? 1 : percent;
return Container(
color: Colors.red,
child: Stack(
children: <Widget>[
_buildTitle(shrinkOffset),
_buildLeftImage(percent),
_buildRightImage(percent),
],
),
);
}
Size _calculateTitleSize(String text, TextStyle style) {
final TextPainter textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr)
..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
}
Widget _buildTitle(double shrinkOffset) => Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: _topPadding!),
child: Opacity(
opacity: shrinkOffset / maxExtent,
child: Text(title, key: _titleKey, style: titleStyle),
),
),
);
double getScaledWidth(double width, double percent) =>
width - ((width - minWidth) * percent);
double getScaledHeight(double height, double percent) =>
height - ((height - minHeight) * percent);
/// 20 is the padding between the image and the title
double get shrinkedHorizontalPos =>
(_centerX! - (_titleSize!.width / 2)) - minWidth - 20;
Widget _buildLeftImage(double percent) {
final double topMargin = minExtent;
final double rangeLeft =
(_centerX! - (leftMaxWidth / 2)) - shrinkedHorizontalPos;
final double rangeTop = topMargin - _shrinkedTopPos;
final double top = topMargin - (rangeTop * percent);
final double left =
(_centerX! - (leftMaxWidth / 2)) - (rangeLeft * percent);
return Positioned(
left: left,
top: top,
child: Container(
width: getScaledWidth(leftMaxWidth, percent),
height: getScaledHeight(leftMaxHeight, percent),
color: Colors.black,
),
);
}
Widget _buildRightImage(double percent) {
final double topMargin = minExtent + (rightMaxHeight / 2);
final double rangeRight =
(_centerX! - (rightMaxWidth / 2)) - shrinkedHorizontalPos;
final double rangeTop = topMargin - _shrinkedTopPos;
final double top = topMargin - (rangeTop * percent);
final double right =
(_centerX! - (rightMaxWidth / 2)) - (rangeRight * percent);
return Positioned(
right: right,
top: top,
child: Container(
width: getScaledWidth(rightMaxWidth, percent),
height: getScaledHeight(rightMaxHeight, percent),
color: Colors.white,
),
);
}
@override
double get maxExtent => 300;
@override
double get minExtent => _topPadding! + 50;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
false;
}
公式有点乱,但您可以通过以下方式进行有关动画的所有计算:
UPD: 添加到代码变量,使扩展时图像的 Y 轴偏移。
要重现的完整代码:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Material App',
home: Body(),
);
}
}
class Body extends StatefulWidget {
const Body({
Key key,
}) : super(key: key);
@override
_BodyState createState() => _BodyState();
}
class _BodyState extends State<Body> {
double _collapsedHeight = 60;
double _expandedHeight = 200;
double
extentRatio; // Value to control SliverAppBar widget sizes, based on BoxConstraints and
double minH1 = 40; // Minimum height of the first image.
double minW1 = 30; // Minimum width of the first image.
double minH2 = 20; // Minimum height of second image.
double minW2 = 25; // Minimum width of second image.
double maxH1 = 60; // Maximum height of the first image.
double maxW1 = 60; // Maximum width of the first image.
double maxH2 = 40; // Maximum height of second image.
double maxW2 = 50; // Maximum width of second image.
double textWidth = 70; // Width of a given title text.
double extYAxisOff = 10.0; // Offset on Y axis for both images when sliver is extended.
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
collapsedHeight: _collapsedHeight,
expandedHeight: _expandedHeight,
floating: true,
pinned: true,
flexibleSpace: LayoutBuilder(
builder:
(BuildContext context, BoxConstraints constraints) {
extentRatio =
(constraints.biggest.height - _collapsedHeight) /
(_expandedHeight - _collapsedHeight);
double xAxisOffset1 = (-(minW1 - minW2) -
textWidth +
(textWidth + maxW1) * extentRatio) /
2;
double xAxisOffset2 = (-(minW1 - minW2) +
textWidth +
(-textWidth - maxW2) * extentRatio) /
2;
double yAxisOffset2 = (-(minH1 - minH2) -
(maxH1 - maxH2 - (minH1 - minH2)) *
extentRatio) /
2 -
extYAxisOff * extentRatio;
double yAxisOffset1 = -extYAxisOff * extentRatio;
print(extYAxisOff);
// debugPrint('constraints=' + constraints.toString());
// debugPrint('Scale ratio is $extentRatio');
return FlexibleSpaceBar(
titlePadding: EdgeInsets.all(0),
// centerTitle: true,
title: Stack(
children: [
Align(
alignment: Alignment.topCenter,
child: AnimatedOpacity(
duration: Duration(milliseconds: 300),
opacity: extentRatio < 1 ? 1 : 0,
child: Padding(
padding: const EdgeInsets.only(top: 30.0),
child: Container(
color: Colors.indigo,
width: textWidth,
alignment: Alignment.center,
height: 20,
child: Text(
"TITLE TEXT",
style: TextStyle(
color: Colors.white,
fontSize: 12.0,
),
),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
transform: Matrix4(
1,0,0,0,
0,1,0,0,
0,0,1,0,
xAxisOffset1,yAxisOffset1,0,1),
width:
minW1 + (maxW1 - minW1) * extentRatio,
height:
minH1 + (maxH1 - minH1) * extentRatio,
color: Colors.red,
),
Container(
transform: Matrix4(
1,0,0,0,
0,1,0,0,
0,0,1,0,
xAxisOffset2,yAxisOffset2,0,1),
width:
minW2 + (maxW2 - minW2) * extentRatio,
height:
minH2 + (maxH2 - minH2) * extentRatio,
color: Colors.purple,
),
],
),
),
],
),
);
},
)),
];
},
body: Center(
child: Text("Sample Text"),
),
),
),
);
}
}
我对 Appbar 有这些要求,但我找不到解决它们的方法。
- 当拉伸时,AppBar 必须显示两个图像一个在另一个上面并且标题必须隐藏。
- 当关闭时,AppBar必须显示标题并且滚动时必须缩小两个图像并移动到标题的两侧.滚动时标题可见。
我创建了几个 mock-ups 来帮助获得所需的结果。
这是拉伸后的应用栏:
这是关闭时的应用栏:
您可以通过扩展 SliverPersistentHeaderDelegate
创建自己的 SliverAppBar
。
平移、缩放和不透明度更改将在 build(...)
方法中完成,因为这将在范围更改(通过滚动)期间调用,minExtent <-> maxExtent
。
这是示例代码。
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primaryColor: Colors.blue,
),
home: HomePage(),
);
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
delegate: MySliverAppBar(
title: 'Sample',
minWidth: 50,
minHeight: 25,
leftMaxWidth: 200,
leftMaxHeight: 100,
rightMaxWidth: 100,
rightMaxHeight: 50,
shrinkedTopPos: 10,
),
pinned: true,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, int i) => Container(
height: 50,
color: Color.fromARGB(
255,
Random().nextInt(255),
Random().nextInt(255),
Random().nextInt(255),
),
),
childCount: 50,
),
),
],
),
);
}
}
class MySliverAppBar extends SliverPersistentHeaderDelegate {
MySliverAppBar({
required this.title,
required this.minWidth,
required this.minHeight,
required this.leftMaxWidth,
required this.leftMaxHeight,
required this.rightMaxWidth,
required this.rightMaxHeight,
this.titleStyle = const TextStyle(fontSize: 26),
this.shrinkedTopPos = 0,
});
final String title;
final TextStyle titleStyle;
final double minWidth;
final double minHeight;
final double leftMaxWidth;
final double leftMaxHeight;
final double rightMaxWidth;
final double rightMaxHeight;
final double shrinkedTopPos;
final GlobalKey _titleKey = GlobalKey();
double? _topPadding;
double? _centerX;
Size? _titleSize;
double get _shrinkedTopPos => _topPadding! + shrinkedTopPos;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
if (_topPadding == null) {
_topPadding = MediaQuery.of(context).padding.top;
}
if (_centerX == null) {
_centerX = MediaQuery.of(context).size.width / 2;
}
if (_titleSize == null) {
_titleSize = _calculateTitleSize(title, titleStyle);
}
double percent = shrinkOffset / (maxExtent - minExtent);
percent = percent > 1 ? 1 : percent;
return Container(
color: Colors.red,
child: Stack(
children: <Widget>[
_buildTitle(shrinkOffset),
_buildLeftImage(percent),
_buildRightImage(percent),
],
),
);
}
Size _calculateTitleSize(String text, TextStyle style) {
final TextPainter textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr)
..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
}
Widget _buildTitle(double shrinkOffset) => Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: _topPadding!),
child: Opacity(
opacity: shrinkOffset / maxExtent,
child: Text(title, key: _titleKey, style: titleStyle),
),
),
);
double getScaledWidth(double width, double percent) =>
width - ((width - minWidth) * percent);
double getScaledHeight(double height, double percent) =>
height - ((height - minHeight) * percent);
/// 20 is the padding between the image and the title
double get shrinkedHorizontalPos =>
(_centerX! - (_titleSize!.width / 2)) - minWidth - 20;
Widget _buildLeftImage(double percent) {
final double topMargin = minExtent;
final double rangeLeft =
(_centerX! - (leftMaxWidth / 2)) - shrinkedHorizontalPos;
final double rangeTop = topMargin - _shrinkedTopPos;
final double top = topMargin - (rangeTop * percent);
final double left =
(_centerX! - (leftMaxWidth / 2)) - (rangeLeft * percent);
return Positioned(
left: left,
top: top,
child: Container(
width: getScaledWidth(leftMaxWidth, percent),
height: getScaledHeight(leftMaxHeight, percent),
color: Colors.black,
),
);
}
Widget _buildRightImage(double percent) {
final double topMargin = minExtent + (rightMaxHeight / 2);
final double rangeRight =
(_centerX! - (rightMaxWidth / 2)) - shrinkedHorizontalPos;
final double rangeTop = topMargin - _shrinkedTopPos;
final double top = topMargin - (rangeTop * percent);
final double right =
(_centerX! - (rightMaxWidth / 2)) - (rangeRight * percent);
return Positioned(
right: right,
top: top,
child: Container(
width: getScaledWidth(rightMaxWidth, percent),
height: getScaledHeight(rightMaxHeight, percent),
color: Colors.white,
),
);
}
@override
double get maxExtent => 300;
@override
double get minExtent => _topPadding! + 50;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
false;
}
公式有点乱,但您可以通过以下方式进行有关动画的所有计算:
UPD: 添加到代码变量,使扩展时图像的 Y 轴偏移。
要重现的完整代码:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Material App',
home: Body(),
);
}
}
class Body extends StatefulWidget {
const Body({
Key key,
}) : super(key: key);
@override
_BodyState createState() => _BodyState();
}
class _BodyState extends State<Body> {
double _collapsedHeight = 60;
double _expandedHeight = 200;
double
extentRatio; // Value to control SliverAppBar widget sizes, based on BoxConstraints and
double minH1 = 40; // Minimum height of the first image.
double minW1 = 30; // Minimum width of the first image.
double minH2 = 20; // Minimum height of second image.
double minW2 = 25; // Minimum width of second image.
double maxH1 = 60; // Maximum height of the first image.
double maxW1 = 60; // Maximum width of the first image.
double maxH2 = 40; // Maximum height of second image.
double maxW2 = 50; // Maximum width of second image.
double textWidth = 70; // Width of a given title text.
double extYAxisOff = 10.0; // Offset on Y axis for both images when sliver is extended.
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
collapsedHeight: _collapsedHeight,
expandedHeight: _expandedHeight,
floating: true,
pinned: true,
flexibleSpace: LayoutBuilder(
builder:
(BuildContext context, BoxConstraints constraints) {
extentRatio =
(constraints.biggest.height - _collapsedHeight) /
(_expandedHeight - _collapsedHeight);
double xAxisOffset1 = (-(minW1 - minW2) -
textWidth +
(textWidth + maxW1) * extentRatio) /
2;
double xAxisOffset2 = (-(minW1 - minW2) +
textWidth +
(-textWidth - maxW2) * extentRatio) /
2;
double yAxisOffset2 = (-(minH1 - minH2) -
(maxH1 - maxH2 - (minH1 - minH2)) *
extentRatio) /
2 -
extYAxisOff * extentRatio;
double yAxisOffset1 = -extYAxisOff * extentRatio;
print(extYAxisOff);
// debugPrint('constraints=' + constraints.toString());
// debugPrint('Scale ratio is $extentRatio');
return FlexibleSpaceBar(
titlePadding: EdgeInsets.all(0),
// centerTitle: true,
title: Stack(
children: [
Align(
alignment: Alignment.topCenter,
child: AnimatedOpacity(
duration: Duration(milliseconds: 300),
opacity: extentRatio < 1 ? 1 : 0,
child: Padding(
padding: const EdgeInsets.only(top: 30.0),
child: Container(
color: Colors.indigo,
width: textWidth,
alignment: Alignment.center,
height: 20,
child: Text(
"TITLE TEXT",
style: TextStyle(
color: Colors.white,
fontSize: 12.0,
),
),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
transform: Matrix4(
1,0,0,0,
0,1,0,0,
0,0,1,0,
xAxisOffset1,yAxisOffset1,0,1),
width:
minW1 + (maxW1 - minW1) * extentRatio,
height:
minH1 + (maxH1 - minH1) * extentRatio,
color: Colors.red,
),
Container(
transform: Matrix4(
1,0,0,0,
0,1,0,0,
0,0,1,0,
xAxisOffset2,yAxisOffset2,0,1),
width:
minW2 + (maxW2 - minW2) * extentRatio,
height:
minH2 + (maxH2 - minH2) * extentRatio,
color: Colors.purple,
),
],
),
),
],
),
);
},
)),
];
},
body: Center(
child: Text("Sample Text"),
),
),
),
);
}
}