持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情
前言
今天用Flutter来实现类似wx朋友圈的视频播放效果,包括拖拽关闭,路由动画,视频播放等相关的处理。
先来看看效果
(示例视频来自weibo)
视频播放使用的是video_player插件。
Hero
首先可以看到,进入到全屏播放会经过一个缩放位移,以及背景渐入的动画。对Flutter有点了解的话,就可以知道这里是一个Hero的过渡,以及背景的渐变。
Hero则比较简单,外边的组件,和视频页的视频播放器都套上一个Hero,并指定上对应的tag即可。
Hero(
tag: 'video_page_player',
child: Container(), // 对应的组件
)
进入时背景的渐变用系统自带的就刚刚好。
拖拽效果
退出时是有拖拽控制的,且拖拽时背景的透明度会对应变化。所以我们先定义一个继承PageRoute
的类,这里我取名为DragBottomDismissDialog
(虽然实际是页面),可以混入CupertinoRouteTransitionMixin
或MaterialRouteTransitionMixin
,拿到默认的路由配置。
class DragBottomDismissDialog<T> extends PageRoute<T>
with CupertinoRouteTransitionMixin
这样我们等会就可以控制路由的动画了。然后定义个拖拽的监听,用GestureDetector
就可以
GestureDetector(
onPanStart: (details) {
// 拖拽开始
},
onPanUpdate: (details) {
// 拖拽中
},
onPanEnd: (details) {
// 拖拽结束
},
onPanCancel: () {
// 拖拽取消
},
child: child,
)
child的变换有大小变换和位置变换,所以套上这两个
Transform.translate(
offset: offset,
child: Transform.scale(
scale: scale,
child: child,
),
)
因为变化很频繁,最好不用setState来更新,用ValueListenableBuilder来控制
ValueListenableBuilder<Offset>(
valueListenable: _offsetNotifier,
builder: (context, offset, child) {
return Transform.translate(
offset: offset,
child: ValueListenableBuilder<double>(
valueListenable: _scaleNotifier,
builder: (context, scale, child) {
return Transform.scale(
scale: scale,
child: RepaintBoundary(
child: widget.child,
),
);
},
),
);
},
)
然后通过GestureDetector
来更新他们
void onPanUpdate(DragUpdateDetails details) {
_offsetNotifier.value += details.delta;
if (isChildBelowMid(_offsetNotifier.value.dy)) {
// dy : sy = x : 1 - min
_scaleNotifier.value = 1 -
(_offsetNotifier.value.dy /
midDy *
(1 - DragBottomPopSheet.minScale));
widget.animationController.value = 1 - (_offsetNotifier.value.dy / midDy);
} else {
if (_scaleNotifier.value != 1) {
_scaleNotifier.value = 1;
widget.animationController.value = 1;
}
}
if (details.delta.dy > 0) {
_isBottomDir = true;
} else {
_isBottomDir = false;
}
}
void onPanEnd(DragEndDetails details) {
if (isChildBelowMid(_offsetNotifier.value.dy - 100)) {
if (_isBottomDir) {
closing();
return;
}
}
// 没达到可关闭的距离,重置
_lastScale = _scaleNotifier.value;
_lastOffset = _offsetNotifier.value;
_lastFade = widget.animationController.value;
_resetController.forward(from: 0);
}
同时控制widget.animationController
,即传入的路由的AnimationController,用它来控制背景的渐变。
isChildBelowMid
,我们可以注意到视频是向下拖拽才会开始缩小,背景渐淡,所以判断一下是否向下移动
bool isChildBelowMid(double dy) {
return _offsetNotifier.value.dy > 0;
}
结束时超过下边多少距离才会触发关闭,所以onPanEnd
时的判断 - 100,即超过下边100距离。
没有超过的话,需要重置,为了方便,定义了一个_resetController
来控制各属性的重置。
_resetController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
_resetAnimation = CurvedAnimation(
parent: _resetController,
curve: Curves.easeOut,
)..addListener(() {
_scaleNotifier.value =
_resetAnimation.value * (1 - _lastScale) + _lastScale;
widget.animationController.value =
_resetAnimation.value * (1 - _lastFade) + _lastFade;
double dx =
_resetAnimation.value * (1 - _lastOffset.dx) + _lastOffset.dx;
double dy =
_resetAnimation.value * (1 - _lastOffset.dy) + _lastOffset.dy;
_offsetNotifier.value = Offset(dx, dy);
});
若是我想控制拖拽的区域怎么办?
实际操作时,可能只想拖拽视频,或者说固定区域,然后变换整个页面。那要怎么做才能将GestureDetector
套在需要的区域,又能处理整个页面的组件呢?
可以利用NotificationListener。
NotificationListener可以监听到子组件对应的Notification的信息,也就说GestureDetector
我们可以分离处理,将对应的信息向上发送过去即可。
定义我们的Notification
,即DragStart、DragUpdate、DragEnd、DragCancel
class DragBottomStartNotification extends Notification {
final DragStartDetails details;
DragBottomStartNotification(this.details);
}
class DragBottomUpdateNotification extends Notification {
final DragUpdateDetails details;
DragBottomUpdateNotification(this.details);
}
class DragBottomEndNotification extends Notification {
final DragEndDetails details;
DragBottomEndNotification(this.details);
}
class DragBottomCancelNotification extends Notification {
DragBottomCancelNotification();
}
新定义一个组件,用来套在我们需要拖拽的区域中,并将信息发送过去
/// 指定用于拖拽的区域
/// 包裹 [child], 则只有[child]会被响应拖拽事件
class DragBottomPopGesture extends StatelessWidget {
final Widget child;
const DragBottomPopGesture({Key? key, required this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: (details) {
DragBottomStartNotification(details).dispatch(context);
},
onPanUpdate: (details) {
DragBottomUpdateNotification(details).dispatch(context);
},
onPanEnd: (details) {
DragBottomEndNotification(details).dispatch(context);
},
onPanCancel: () {
DragBottomCancelNotification().dispatch(context);
},
child: child,
);
}
}
Notification().dispath(context)
即可向上发送信息,NotificationListener
就能监听到了。
NotificationListener(
onNotification: (notification) {
if (notification is DragBottomStartNotification) {
onPanStart(notification.details);
} else if (notification is DragBottomUpdateNotification) {
onPanUpdate(notification.details);
} else if (notification is DragBottomEndNotification) {
onPanEnd(notification.details);
} else if (notification is DragBottomCancelNotification) {
onPanCancel();
}
return false;
},
背景渐变跟随
将我们处理数据的类封装成个组件,方便使用。在路由动画控制的地方(DragBottomDismissDialog
)套上
@override
Widget buildContent(BuildContext context) {
return DragBottomPopSheet(
animationController: _animationController!,
fadeAnimationController: _fadeAnimationController!,
onClosing: () {
_animationController!.value = 1;
_fadeAnimationController!.animateTo(0, duration: transitionDuration);
Navigator.pop(context);
},
child: builder(context),
);
}
需要注意,仅影响组件的,写在buildContent
中。写在buildTransitions
的话,会连背景一起拖拽。
buildTransitions
中单独对背景的渐变进行控制
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return Stack(
children: [
FadeTransition(
opacity: isActive
? _animationController!
: _fadeAnimationController!,
child: Container(
color: Colors.black,
),
),
child,
],
);
}
分了两个controller
,_animationController
是进入时的,_fadeAnimationController
是退出时的。为什么呢?因为只用一个Controller
的话,拖拽时会同时改变动画的value
,松手时,总的Duration
就已经消耗部分了,Hero
动画也是基于路由的Controller,他的Duration
就相当于变少了,体验上就会很快速的变到初始位置,再快速的弹回去。
所以上面在关闭时,先将路由的Controller
重置,使他可以完整的走一个Hero流程,然后为了让背景的渐变从松手时开始变化(而不是变回透明度0),单独的_fadeAnimationController
来控制就可以使它同步流畅的渐出。
测试一下拖拽效果
这时候我们就可以用DragBottomDismissDialog
来打开一个可拖拽关闭的页面了。
测试一下
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: GestureDetector(
onTap: () {
Navigator.push(
context,
DragBottomDismissDialog(
builder: (context) {
return TestPage();
},
),
);
},
child: Hero(
tag: 'test_tag',
child: Container(
width: 200,
height: 100,
color: Colors.blue,
),
),
),
);
注意Hero要加上,且tag对应
TestPage:
return Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: DragBottomPopGesture(
child: Hero(
tag: "test_tag",
child: Container(
width: double.maxFinite,
height: 400,
color: Colors.red,
),
),
),
),
);
打开页面的背景色要改为透明的,不然会遮挡。
示例效果
相关文章
拖拽效果完成,也可以用在其他功能中。
视频播放效果可以看Flutter 实现朋友圈视频播放效果(下)-- 视频效果