• 作者:老汪软件技巧
  • 发表时间: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 全家桶,真香。