Flutter 实现朋友圈视频播放效果(上)-- 拖拽效果

2,505 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

前言

今天用Flutter来实现类似wx朋友圈的视频播放效果,包括拖拽关闭,路由动画,视频播放等相关的处理。

先来看看效果

video_iu.gif

(示例视频来自weibo)

视频播放使用的是video_player插件。

Hero

首先可以看到,进入到全屏播放会经过一个缩放位移,以及背景渐入的动画。对Flutter有点了解的话,就可以知道这里是一个Hero的过渡,以及背景的渐变

Hero则比较简单,外边的组件,和视频页的视频播放器都套上一个Hero,并指定上对应的tag即可。

Hero(
  tag: 'video_page_player',
  child: Container(),  // 对应的组件
)

进入时背景的渐变用系统自带的就刚刚好。

拖拽效果

退出时是有拖拽控制的,且拖拽时背景的透明度会对应变化。所以我们先定义一个继承PageRoute的类,这里我取名为DragBottomDismissDialog(虽然实际是页面),可以混入CupertinoRouteTransitionMixinMaterialRouteTransitionMixin,拿到默认的路由配置。

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,即DragStartDragUpdateDragEndDragCancel

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,
        ),
      ),
    ),
  ),
);

打开页面的背景色要改为透明的,不然会遮挡。

示例效果

iShot_2022-06-09_17.36.59.gif

相关文章

拖拽效果完成,也可以用在其他功能中。

视频播放效果可以看Flutter 实现朋友圈视频播放效果(下)-- 视频效果