Flutter仿Apple Music播放界面上滑抽屉
- 先看Apple Music的效果,底部播放控制滑动打开或关闭播放界面,滑动跟随手指,伴随着图片的放大缩小和其他控件的显示隐藏。
实现步骤
-
第一步先实现上下滑动。
首先界面分两部分,一部分是我们要实现可滑动的播放界面,一部分是主页面,两部分布局使用
Stack
嵌套,class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: new Scaffold( body: new SafeArea( child: new Stack( children: <Widget>[ new Container( color: Colors.blue, child: new Center( child: new Text("这里是主页面"), ), ), new BottomDrawer(), ], ), ), ), ); } }
image-20190713162859421BottomDrawer
就是可滑动的抽屉,实现滑动事件需要GestureDetector
组件,它封装了常用的手势操作,具体如下:
这我们使用到只有 onVerticalDra
垂直滑动的相关事件,其他不做过多解释。如何让组件跟随手指滑动呢。这里使用 Transform
类,它能实现平移、旋转、缩放等操作。
class BottomDrawer extends StatefulWidget {
@override
State<StatefulWidget> createState() => _BottomDrawer();
}
class _BottomDrawer extends State<BottomDrawer> {
///底部预显示高度
final double defaultDisplayOffset = 100.0;
///默认偏移 就是初始化的偏移位置
double defaultOffset;
///当前滑动的位置
double offsetDistance;
///屏幕高度
double screenHeight;
void _onDragUpdate(DragUpdateDetails details) {
///details.delta.dy 拿到此次滑动的偏移高度
offsetDistance = offsetDistance + details.delta.dy;
setState(() {});
}
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
screenHeight = MediaQuery.of(context).size.height;
///默认偏移 等于屏幕高度减去 底部预显示的高度
defaultOffset = screenHeight - defaultDisplayOffset;
offsetDistance = defaultOffset;
}
@override
Widget build(BuildContext context) {
return new Transform.translate(
offset: Offset(0.0, offsetDistance),
child: new GestureDetector(
onVerticalDragUpdate: _onDragUpdate,
child: new Container(
color: Colors.white,
),
),
);
}
}
监听滑动的偏移值,然后更新 Transform
的偏移值,跟手滑动就实现了,就这么简单。
-
第二步实现弹性滑动,当上滑一定的值通过动画让它自动滑动到顶部,下滑反之。监听
onVerticalDragEnd
滑动结束,在这里判断滑动反向,然后判断滑动距离是有大于有效值,大于的话上滑直接滑动到顶部,下滑直接滑动到底部。小于有效值的话就判断滑动无效,从哪里来回哪里去。首先记住滑动开始的位置
///滑动开始 void _onDragStart(DragStartDetails details) { dragStartOffset = offsetDistance; }
然后在滑动结束的时候判断此次滑动是否有效
///是否是向上滑动 当前位置小于开始位置为向上 final bool isUp = offsetDistance < dragStartOffset; double endOffset; ///滑动距离绝对值大于 有效范围 说明滑动有效 上滑到顶 下滑到底部 ///否的话 无效 从哪里来回哪里去 底部上滑回底部 顶部下滑回顶部 if ((offsetDistance - dragStartOffset).abs() > offsetRange) { endOffset = isUp ? 0 : defaultOffset; } else { endOffset = isUp ? defaultOffset : 0; } _animationDrag(endOffset);
当判断滑动有效调用
_animationDrag
方法动画执行到顶部或底部///动画控制器 AnimationController animationController; ///动画 Animation<double> animation; ///动画值是否重置 bool onResetControllerValue = false; @override void initState() { super.initState(); ///duration 动画执行时间 ///vsync 防止UI不在焦点界面继续执行 消耗不必要的资源 animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 250)); } @override void dispose() { super.dispose(); animationController.dispose(); }
void _animationDrag(double endOffset) { ///执行动画前需要将上次的动画的值清除 ///但是对动画进行了监听 清除赋值为0的时候会刷新界面 ///所以加个bool值判断 onResetControllerValue = true; animationController.value = 0.0; onResetControllerValue = false; ///动画执行规律曲线 final CurvedAnimation curve = new CurvedAnimation(parent: animationController, curve: Curves.easeIn); ///动画执行范围 开始为当前偏移量 结束为目标偏移量 animation = Tween(begin: offsetDistance, end: endOffset).animate(curve) ..addListener(() { ///animation.value拿到之后赋值给offsetDistance然后刷新界面 if (!onResetControllerValue) { offsetDistance = animation.value; setState(() {}); } }); ///启动动画 animationController.forward(); }
这里的动画可以简单理解为在给定的执行规律、范围、时间下,有规律的修改
Jul-13-2019 17-39-40animation.value
的值,而我们做的就是监听这个值的变化然后根据这个值去刷新界面。跟多动画相关这里不做深究。 -
第三步就是图片的缩放,组件透明度的修改了,图片的缩放我们滑动的比例来计算图片的宽高和边界,透明渐变的话我这里使用了
Opacity
组件,在需要修改透明度的组件外面套一层Opacity
, 修改它的opacity
值就能修改透明度了。
image///当前滑动偏移的百分比 final double offsetScale; ///封面最小宽度 final double imageMinWidth = 52.0; ///封面图片最大边界 final double imageMaxMargin = 48.0; ///封面图片最小边界 final double imageMinMargin = 12.0; PlayMain({Key key, this.offsetScale}) : super(key: key); @override Widget build(BuildContext context) { ///封面最大宽度 final imageMaxWidth = MediaQuery.of(context).size.width - imageMaxMargin * 2; ///底部小控制器透明度 double smallOpacity = offsetScale; /// 主播放界面组件透明度 double mainOpacity = 1.0 - offsetScale; mainOpacity = max(0, mainOpacity); final imageWidth = (imageMaxWidth - imageMinWidth) * mainOpacity + imageMinWidth; ///封面边界 double imageMargin = (imageMaxMargin - imageMinMargin) * mainOpacity + imageMinMargin; return new GestureDetector( child: new Column( children: <Widget>[ new Stack( children: <Widget>[ ///底部小控制器 new Opacity( opacity: smallOpacity, child: new Container( height: imageMinWidth + imageMinMargin * 2, child: new Row( crossAxisAlignment: CrossAxisAlignment.center, children: <Widget>[ new Padding( padding: EdgeInsets.only( left: (imageMinWidth + imageMinMargin * 2))), new Expanded(child: new Text("阴天快乐")), new IconButton( icon: new Icon(Icons.stop), onPressed: () { Fluttertoast.showToast(msg: "播放暂停按钮"); }), new IconButton( icon: new Icon(Icons.skip_next), onPressed: () { Fluttertoast.showToast(msg: "下一曲"); }), ], ), ), ), ///图片 new Container( margin: EdgeInsets.only(left: imageMargin, top: imageMargin), child: new Image( width: imageWidth, height: imageWidth, fit: BoxFit.fitHeight, image: new NetworkImage( "https://p2.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg")), ), ], ), ///主播放界面控制器 new Opacity( opacity: mainOpacity, child: new Container( margin: const EdgeInsets.only( left: 24, right: 24, top: 36, ), child: new Column( mainAxisSize: MainAxisSize.max, children: <Widget>[ new LinearProgressIndicator( backgroundColor: Colors.pink[200], value: 0.5, valueColor: new AlwaysStoppedAnimation<Color>(Colors.pink), ), new Row( children: <Widget>[ Text("2:19"), Expanded( child: new Container(), ), Text("5:00"), ], ), new Text("阴天快乐"), new Text("陈奕迅-rice & shine"), ], ), ), ) ], ), ); }
现在基本就完成了仿Apple Music的底部上滑抽屉,根据滑动距离实现缩放和渐变的动画,代码还是比较简单。由于是demo,所有代码比较杂乱,还有一些计算的边界处理没有做。附上所有源码:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
home: new Scaffold(
body: new SafeArea(
child: new Stack(
children: <Widget>[
new Container(
color: Colors.blue,
child: new Center(
child: new Text("这里是主页面"),
),
),
new BottomDrawer(),
],
),
),
),
);
}
}
class BottomDrawer extends StatefulWidget {
@override
State<StatefulWidget> createState() => _BottomDrawer();
}
class _BottomDrawer extends State<BottomDrawer> with TickerProviderStateMixin {
///底部预显示高度
final double defaultDisplayOffset = 100.0;
///滑动有效范围
final double offsetRange = 100;
///默认偏移 就是初始化的偏移位置
double defaultOffset;
///当前滑动的位置
double offsetDistance;
///滑动开始的位置
double dragStartOffset;
///屏幕高度
double screenHeight;
///动画控制器
AnimationController animationController;
///动画
Animation<double> animation;
///动画值是否重置
bool onResetControllerValue = false;
@override
void initState() {
super.initState();
///duration 动画执行时间
///vsync 防止UI不在焦点界面继续执行 消耗不必要的资源
animationController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 250));
}
@override
void dispose() {
super.dispose();
animationController.dispose();
}
///滑动开始
void _onDragStart(DragStartDetails details) {
dragStartOffset = offsetDistance;
}
///滑动过程中位置更新
void _onDragUpdate(DragUpdateDetails details) {
///details.delta.dy 拿到此次滑动的偏移高度
offsetDistance = offsetDistance + details.delta.dy;
setState(() {});
}
void _onDragEnd(DragEndDetails details) {
///是否是向上滑动 当前位置小于开始位置为向上
final bool isUp = offsetDistance < dragStartOffset;
double endOffset;
///滑动距离绝对值大于 有效范围 说明滑动有效 上滑到顶 下滑到底部
///否的话 无效 从哪里来回哪里去 底部上滑回底部 顶部下滑回顶部
if ((offsetDistance - dragStartOffset).abs() > offsetRange) {
endOffset = isUp ? 0 : defaultOffset;
} else {
endOffset = isUp ? defaultOffset : 0;
}
_animationDrag(endOffset);
}
void _animationDrag(double endOffset) {
///执行动画前需要将上次的动画的值清除
///但是对动画进行了监听 清除赋值为0的时候会刷新界面
///所以加个bool值判断
onResetControllerValue = true;
animationController.value = 0.0;
onResetControllerValue = false;
///动画执行曲线
final CurvedAnimation curve =
new CurvedAnimation(parent: animationController, curve: Curves.easeIn);
///动画执行范围 开始为当前偏移量 结束为目标偏移量
animation = Tween(begin: offsetDistance, end: endOffset).animate(curve)
..addListener(() {
///animation.value拿到之后赋值给offsetDistance然后刷新界面
if (!onResetControllerValue) {
offsetDistance = animation.value;
setState(() {});
}
});
///启动动画
animationController.forward();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
screenHeight = MediaQuery.of(context).size.height;
///默认偏移 等于屏幕高度减去 底部预显示的高度
defaultOffset = screenHeight - defaultDisplayOffset;
offsetDistance = defaultOffset;
}
@override
Widget build(BuildContext context) {
///当前偏移比例
final double scale = offsetDistance / defaultOffset;
print("scale:$scale");
return new Transform.translate(
offset: Offset(0.0, offsetDistance),
child: new GestureDetector(
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragStart: _onDragStart,
onVerticalDragEnd: _onDragEnd,
child: new Container(
color: Colors.white,
child: PlayMain(offsetScale: scale),
),
),
);
}
}
class PlayMain extends StatelessWidget {
///当前滑动偏移的百分比
final double offsetScale;
///封面最小宽度
final double imageMinWidth = 52.0;
///封面图片最大边界
final double imageMaxMargin = 48.0;
///封面图片最小边界
final double imageMinMargin = 12.0;
PlayMain({Key key, this.offsetScale}) : super(key: key);
@override
Widget build(BuildContext context) {
///封面最大宽度
final imageMaxWidth =
MediaQuery.of(context).size.width - imageMaxMargin * 2;
///底部小控制器透明度
double smallOpacity = offsetScale;
/// 主播放界面组件透明度
double mainOpacity = 1.0 - offsetScale;
mainOpacity = max(0, mainOpacity);
final imageWidth =
(imageMaxWidth - imageMinWidth) * mainOpacity + imageMinWidth;
///封面边界
double imageMargin =
(imageMaxMargin - imageMinMargin) * mainOpacity + imageMinMargin;
return new GestureDetector(
child: new Column(
children: <Widget>[
new Stack(
children: <Widget>[
///底部小控制器
new Opacity(
opacity: smallOpacity,
child: new Container(
height: imageMinWidth + imageMinMargin * 2,
child: new Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
new Padding(
padding: EdgeInsets.only(
left: (imageMinWidth + imageMinMargin * 2))),
new Expanded(child: new Text("阴天快乐")),
new IconButton(
icon: new Icon(Icons.stop),
onPressed: () {
Fluttertoast.showToast(msg: "播放暂停按钮");
}),
new IconButton(
icon: new Icon(Icons.skip_next),
onPressed: () {
Fluttertoast.showToast(msg: "下一曲");
}),
],
),
),
),
///图片
new Container(
margin: EdgeInsets.only(left: imageMargin, top: imageMargin),
child: new Image(
width: imageWidth,
height: imageWidth,
fit: BoxFit.fitHeight,
image: new NetworkImage(
"https://p2.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg")),
),
],
),
///主播放界面控制器
new Opacity(
opacity: mainOpacity,
child: new Container(
margin: const EdgeInsets.only(
left: 24,
right: 24,
top: 36,
),
child: new Column(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
new LinearProgressIndicator(
backgroundColor: Colors.pink[200],
value: 0.5,
valueColor: new AlwaysStoppedAnimation<Color>(Colors.pink),
),
new Row(
children: <Widget>[
Text("2:19"),
Expanded(
child: new Container(),
),
Text("5:00"),
],
),
new Text("阴天快乐"),
new Text("陈奕迅-rice & shine"),
],
),
),
)
],
),
);
}
}
初学小白的个人学习笔记,欢迎大佬检查,如有不对请指出。
网友评论