- 作者:老汪软件技巧
- 发表时间:2024-09-27 04:00
- 浏览量:
是不是你们最喜欢看的?这是一段有声音的 Gif ,流口水声。
前言
最近微信朋友圈可以发实况图了,上了热搜!
我在想,好家伙, 实况图 (Live Photo) iOS 9.1 就支持了,现在都 iOS 18 了。
我记得 photo_manager 和 wechat_assets_picker 很早就支持了呀!
虽然但是,然后需求它又来了。
微信都不支持,不要太卷了,年轻人。
什么是 实况图 (Live Photo)
要编写这种效果,首先我们要了解一下 实况图 (Live Photo) 是什么。
首先在手机上面制作一个实况图 (Live Photo), 通过隔空投送,发现只有一张 HEIC 格式的图片。
live图片隔空投送到mac上变成HEIC模式… - Apple 社区
后来找到另外的方式,就是在 mac 上面登录跟你手机相同的账号,从相册应用中找到该图片,从 File-> Export-> Export Unmodified Original For 1 Photo 即 文件-> 导出-> 导出未处理的原片 。
objective c - Can I put a live photo into the iOS Simulator? - Stack Overflow
导出之后,是 2 个文件,一个是图片,一个是视频。
当然,你也可以直接使用 photo_manager 读取你手机中的 实况图 (Live Photo)。
系统相册的操作是长按实况图 (Live Photo) 就会播放; 看了下微信的效果,预览的时候。自动播放,这个时候可以手势进行缩放,播放完毕,回到图片状态,保持缩放状态。说实话,做起来应该不难。
开干
图片手势的原理,是通过监听手势,去影响图片最终的绘制区域。所以说要做到视频(任何 Widget)也跟随手势变化,其实只用把手势处理的过程复制一份就好了,然后把结果给视频(任何 Widget),让它绘制到给定区域即可。
原理简读
Widget _buildVideo(ExtendedImageGestureState? imageGestureState) {
// The image to render into the area rect.
// in demo case, it is the page size.
// and you can also get it from LayoutBuilder base on your case.
final Size size = MediaQuery.of(context).size;
final Rect destinationRect = widget.buildWithImageRect
? GestureWidgetDelegateFromState.getRectFormState(
Offset.zero & size,
imageGestureState!,
)
: GestureWidgetDelegateFromState.getRectFormState(
Offset.zero & size,
imageGestureState!,
width: _controller.value.size.width,
height: _controller.value.size.height,
);
final ExtendedImageSlidePageState? extendedImageSlidePageState =
imageGestureState.extendedImageSlidePageState;
Widget child = VideoPlayer(_controller);
if (widget.buildWithImageRect) {
final double aspectRatio = widget.state.extendedImageInfo!.image.width /
widget.state.extendedImageInfo!.image.height;
if ((_controller.value.aspectRatio - aspectRatio).abs() > 0.01) {
final Rect widgetDestinationRect =
GestureWidgetDelegateFromState.getRectFormState(
Offset.zero & size,
imageGestureState,
width: _controller.value.size.width,
height: _controller.value.size.height,
copy: true,
);
child = FittedBox(
child: SizedBox(
child: child,
width: widgetDestinationRect.width,
height: widgetDestinationRect.height,
),
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
);
}
}
child = CustomSingleChildLayout(
delegate: GestureWidgetDelegateFromRect(
destinationRect,
),
child: child,
);
// The same as use CustomSingleChildLayout
// child = Stack(
// children: [
// Positioned.fromRect(
// rect: destinationRect,
// child: child,
// ),
// ],
// );
if (extendedImageSlidePageState != null) {
child = imageGestureState
.widget.extendedImageState.imageWidget.heroBuilderForSlidingPage
?.call(child) ??
child;
if (extendedImageSlidePageState.widget.slideType == SlideType.onlyImage) {
child = Transform.translate(
offset: extendedImageSlidePageState.offset,
child: Transform.scale(
scale: extendedImageSlidePageState.scale,
child: child,
),
);
}
}
return child;
}
获取区域
rect 即图片占用的区域,例子里面是整个页面,你也可以通过 LayoutBuilder 去获取实际的区域
计算出绘制区域
可以根据你自身的需求,如果需要视频(任何 Widget)按照自身的宽高来绘制,那么在 GestureWidgetDelegateFromState.getRectFormState 方法调用的时候传入实际的宽高。
该方法实现为:
static Rect getRectFormState(
Rect rect,
ExtendedImageGestureState state, {
double? width,
double? height,
BoxFit? fit,
bool copy = false,
}) {
final GestureDetails? gestureDetails = state.gestureDetails;
if (gestureDetails != null && gestureDetails.slidePageOffset != null) {
rect = rect.shift(-gestureDetails.slidePageOffset!);
}
Rect destinationRect = getDestinationRect(
rect: rect,
inputSize: Size(
width ??
state.widget.extendedImageState.extendedImageInfo!.image.width
.toDouble(),
height ??
state.widget.extendedImageState.extendedImageInfo!.image.height
.toDouble(),
),
fit: fit ?? state.widget.extendedImageState.imageWidget.fit,
);
if (gestureDetails != null) {
GestureDetails gd = gestureDetails;
if (copy) {
gd = gestureDetails.copy();
}
destinationRect = gd.calculateFinalDestinationRect(rect, destinationRect);
if (gd.slidePageOffset != null) {
destinationRect = destinationRect.shift(gd.slidePageOffset!);
}
}
return destinationRect;
}
}
处理宽高比不近似相等
通过下面的方法,我们可以视频(任何 Widget)进行 conver 操作,使两者显示更自然。
final Rect widgetDestinationRect =
GestureWidgetDelegateFromState.getRectFormState(
Offset.zero & size,
imageGestureState,
width: _controller.value.size.width,
height: _controller.value.size.height,
copy: true,
);
child = FittedBox(
child: SizedBox(
child: child,
width: widgetDestinationRect.width,
height: widgetDestinationRect.height,
),
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
);
处理滑动退出情况应用最终绘制位置
最后一步把视频(任何 Widget) 绘制到处理之后的最终绘制区域,
当然,这是整个流程看起来很复杂,但是只有你需要自定义,你才需要关注它。
也提供了 wrapGestureWidget 方法,可以简单的处理整个过程(除了不同宽高比那部分)
return imageGestureState!.wrapGestureWidget(
VideoPlayer(_controller),
);
上面只是怎么将缩放平移作用在视频(任何 Widget)的过程,其他细节还包括图片和视频(任何 Widget)切换动画,Live Photo 标志添加等细节,知道你们不喜欢看,直接给你们代码链接,有疑问的可以留言讨论。
完整代码: /fluttercand…
优化细节
可能有这种需求,在用户做手势的时候或者滑动退出的时候,会根据情况对视频(任何 Widget) 特殊处理,比如在用户做手势的时候或者滑动退出的时候,停止视频播放,结束之后再继续播放。
滑动退出中
可能用户会有需求,滑动退出过程中停止播放。
ExtendedImageSlidePage 有 onSlidingPage 回调,你可以根据 ExtendedImageSlidePageState.isSliding 来判断,当前是否是在滑动退出手势中。
ExtendedImageSlidePage(
key: slidePagekey,
onSlidingPage: (ExtendedImageSlidePageState state) {
_isSliding.value = state.isSliding;
},
)
手势中
可能用户会有需求,手势过程中停止播放。
GestureConfig 中有回调 gestureDetailsIsChanged, 可以通过该回调知道是否手势正在进行。
ExtendedImage(
image: image,
fit: _fit,
mode: ExtendedImageMode.gesture,
enableSlideOutPage: true,
initGestureConfigHandler: (ExtendedImageState state) {
return GestureConfig(
inPageView: true,
initialScale: 1.0,
maxScale: 5.0,
animationMaxScale: 6.0,
initialAlignment: InitialAlignment.center,
gestureDetailsIsChanged: (GestureDetails? details) {
_gestureDetailsIsChanging.value = true;
_gestureDetailsChangeCompleted();
},
);
},
);
但是手势是有动画,而且没有结束的标志,所以我们需要利用 debounce 防抖,来判断手势是否结束掉了。意思就是如果 100 milliseconds 之后,这个方法不再触发,那么就认为手势已经结束。
late VoidFunction _gestureDetailsChangeCompleted;
@override
void initState() {
super.initState();
_gestureDetailsChangeCompleted = () {
_gestureDetailsIsChanging.value = false;
}.debounce(const Duration(milliseconds: 100));
}
手势结束,可能是用户手没有动了,即用户手指还是按压着的,只是没有变化。所以我们需要另外一个变量来优化这一场景。
Listener(
onPointerDown: (PointerDownEvent event) {
_pointerDown = true;
},
onPointerUp: (PointerUpEvent event) {
_pointerDown = false;
SchedulerBinding.instance.addPostFrameCallback((_) {
continuePlay();
});
},
onPointerCancel: (PointerCancelEvent event) {
_pointerDown = false;
SchedulerBinding.instance.addPostFrameCallback((_) {
continuePlay();
});
},
);
如果用户手指没有抬起,那我们还是不要继续播放视频。
Future<void> _onGestureDetailsIsChanged() async {
if (!_showVideo.value) {
return;
}
if (widget.gestureDetailsIsChanging.value) {
await _controller.pause();
} else if (!_pointerDown) {
await continuePlay();
}
}
结语
完整的例子在:
extended_image/example/lib/pages/complex/live_photo_demo.dart at master · fluttercandies/extended_image ()
wechat_assets_picker 已同步微信实况图效果。
从最开始支持图片的缩放平移,就已经为后续功能铺好路,只要懂得其原理,一切都是水到渠成。
最后想说的是,年轻人还是不要太卷了,如果提前做了,今年的 kpi 又怎么完成呢? 微信怎么可以做? 和 微信都不支持! 同理。
接下来的 kpi :
这只是饼,有可能完成。
爱 Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果
QQ群:181398081
最最后放上 Flutter Candies 全家桶,真香。