Flutter 仿萤石云回放时间轴

687 阅读3分钟

image.png

需求

  1. 进入自动定位到当前最新时间
  2. 时间刻针位置保持不变,只能拖拽时间轴改变时间
  3. 时间轴任意时间都支持拉到时间刻针下
  4. 拉去到时间轴两边顶端自动回弹
  5. 支持自动播放(此处是间隔一分钟时间轴动一下)
  6. 根据不同的时间片段展示不同的颜色
  7. 停止滑动的时候返回当前的时间
import 'dart:async';

import 'package:camera/utils/log_utils.dart';
import 'package:flutter/material.dart';

class VideoSegment {
  final String type;
  final DateTime startTime;
  final DateTime endTime;

  VideoSegment({required this.type, required this.startTime, required this.endTime});
}

class CustomTimeSlider extends StatefulWidget {
  final int startHour;
  final int? startMinute;
  final List<VideoSegment> videoSegments; //视频片段
  final Function(String data) selectTimeCallback; //选择时间回调

  const CustomTimeSlider({
    Key? key,
    required this.videoSegments,
    required this.selectTimeCallback,
    required this.startHour,
    this.startMinute,
  }) : super(key: key);

  @override
  State<CustomTimeSlider> createState() => _CustomTimeSliderState();
}

class _CustomTimeSliderState extends State<CustomTimeSlider> {
  final String tag = "TimeRuler";

  // 存储选择的小时
  double selectedHour = 0;
  final ScrollController _scrollController = ScrollController();
  double timelineWidth = 2400.0; //时间轴宽度
  double timelineHeight = 100.0; //时间轴高度
  late double oneHourWidth; //一小时的宽度
  late double oneMinuteWidth; //一分钟的宽度
  String formattedTime = '';
  Timer? _autoScrollTimer;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    oneHourWidth = timelineWidth / 24;
    oneMinuteWidth = timelineWidth / 24 / 60;

    setOffset(widget.startHour, widget.startMinute ?? 00);
    // 添加滚动监听
    _scrollController.addListener(_onScroll);
  }

  //设置初始偏移
  setOffset(int setHour, int setMinute) {
    Log.d(tag, "setOffset setHour:$setHour setMinute:$setMinute");
    Future.delayed(const Duration(milliseconds: 500), () {
      // 初始化 selectedHour 为当前时间的小时部分
      selectedHour = setHour.toDouble();
      Log.d(tag, "setOffset selectedHour:$selectedHour");
      // 计算当前时间对应的小时偏移量
      double hourOffset = selectedHour * oneHourWidth; //默认从左侧开始,但是前面多了半屏宽度,因此不用减半屏宽度使其居中
      // 分钟部分,可以添加分钟的偏移量
      hourOffset = hourOffset + setMinute * oneMinuteWidth;
      Log.d(tag, "setOffset hourOffset:$hourOffset");
      // 将滚动控制器的偏移量设置为计算的偏移量
      _scrollController.jumpTo(hourOffset);
    });
  }

  //时间轴监听
  void _onScroll() {
    // 获取当前滚动的位置
    double currentPosition = _scrollController.offset;

    // 计算所选小时和分钟
    int newSelectedHour = currentPosition ~/ oneHourWidth;
    double remainingPixels = currentPosition % oneHourWidth;
    int selectedMinute = (remainingPixels / oneMinuteWidth).round();

    // 更新所选小时
    setState(() {
      selectedHour = newSelectedHour + selectedMinute / 60;
    });
    // 在此处可以执行其他与滚动相关的操作
    setFormatHourMinute(selectedHour);

    // 停止之前的自动滚动计时器
    _autoScrollTimer?.cancel();

    // 获取当前时间的秒数
    int currentSecond = DateTime.now().second;
    // 计算距离下一分钟的秒数
    int secondsUntilNextMinute = 60 - currentSecond;

    // 开始自动滚动计时器,首次延迟到下一分钟的0秒触发,然后每分钟触发一次
    _autoScrollTimer = Timer(Duration(seconds: secondsUntilNextMinute), () {
      double targetOffset = _scrollController.offset + oneMinuteWidth;
      Log.d(tag, "_onScroll targetOffset:$targetOffset");
      _scrollController.animateTo(
        targetOffset,
        duration: const Duration(milliseconds: 500),
        curve: Curves.linear,
      );

      // 再次设置计时器,以在下一分钟触发滚动
      _autoScrollTimer = Timer.periodic(const Duration(minutes: 1), (timer) {
        double targetOffset = _scrollController.offset + oneMinuteWidth;
        _scrollController.animateTo(
          targetOffset,
          duration: const Duration(milliseconds: 500),
          curve: Curves.linear,
        );
      });
    });
  }

  //将时间转变为hh:mm
  setFormatHourMinute(double hour) {
    int hourInt = hour.toInt();
    int minute = ((hour - hourInt) * 60).toInt();

    String hourString = hourInt.toString().padLeft(2, '0');
    String minuteString = minute.toString().padLeft(2, '0');
    if (hourString == "24" || hourString == "00") {
      //时间轴被滑动到两端尽头
      minuteString = "00";
    }
    formattedTime = '$hourString:$minuteString';

    // Log.d(tag, "setFormatHourMinute formattedTime:$formattedTime");
  }

  @override
  void dispose() {
    _autoScrollTimer?.cancel();
    // 移除滚动监听
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return NotificationListener<ScrollNotification>(
      onNotification: (ScrollNotification notification) {
        // ScrollStartNotification:当滚动开始时触发的通知。
        // ScrollUpdateNotification:当滚动位置更新时触发的通知。
        // OverscrollNotification:当滚动超出边界时触发的通知。
        // ScrollCancelNotification:当滚动被取消时触发的通知
        // ScrollEndNotification:当滚动停止时触发的通知
        if (notification is ScrollEndNotification) {
          Log.d(tag, "notification dragDetails:${notification.dragDetails}");
          if (notification.dragDetails != null) {
            // 用户停止滑动时间轴,可以在这里执行您的操作 例如,获取所选时间并触发回调函数
            //notification.dragDetails 自动滑动时为null
            widget.selectTimeCallback(formattedTime);
          }
        }
        return false; // 让通知继续传递给其他监听器
      },
      child: Column(
        children: [
          // Text("选择的时间是:$formattedTime"),
          SizedBox(
            height: 50,
            child: Stack(
              children: [
                SizedBox(
                  width: MediaQuery.of(context).size.width,
                  height: 50,
                  child: ListView.builder(
                    scrollDirection: Axis.horizontal,
                    controller: _scrollController,
                    itemBuilder: (context, index) {
                      return Row(
                        children: [
                          SizedBox(width: MediaQuery.of(context).size.width / 2, height: timelineHeight),
                          CustomPaint(
                            size: Size(timelineWidth, timelineHeight), // 调整大小以适应您的需求
                            painter: RulerPainter(videoSegments: widget.videoSegments),
                          ),
                          SizedBox(width: MediaQuery.of(context).size.width / 2, height: timelineHeight),
                        ],
                      );
                    },
                    //回弹效果滚动
                    physics: const BouncingScrollPhysics(),
                    itemCount: 1,
                  ),
                ),
                Positioned(
                  top: 0,
                  left: MediaQuery.of(context).size.width / 2,
                  child: GestureDetector(
                    child: Container(
                      width: 1, // 时间刻针的宽度
                      height: 50, // 时间刻针的高度,与时间标尺高度一致
                      color: Colors.red, // 时间刻针的颜色
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class RulerPainter extends CustomPainter {
  final List<VideoSegment> videoSegments;

  RulerPainter({required this.videoSegments});

  @override
  void paint(Canvas canvas, Size size) {
    final double width = size.width;
    final double height = size.height;
    const int numberPieces = 24; // 一天有24小时
    final double hourWidth = width / numberPieces; // 一小时的宽度
    final double minuteWidth = hourWidth / 60; // 一分钟的宽度

    final Paint linePaint = Paint()
      ..color = Colors.black
      ..strokeWidth = 1.0;

    final Paint backgroundPaint = Paint()..color = Colors.grey;

    final TextPainter textPainter = TextPainter(textDirection: TextDirection.ltr);

    TextStyle textStyle = const TextStyle(color: Colors.black, fontSize: 12);

    //绘制背景颜色
    canvas.drawRect(
      Rect.fromPoints(const Offset(0, 0), Offset(size.width, size.height)),
      backgroundPaint,
    );

    // 绘制刻度线和时间文本
    for (int hour = 0; hour <= numberPieces; hour++) {
      final double x = hour * hourWidth;

      for (int minuteData = 0; minuteData <= 60; minuteData++) {
        // 计算当前分钟对应的总分钟数
        int currentMinute = hour * 60 + minuteData;
        final double minuteX = x + minuteData * minuteWidth;
        // 根据视频片段设置颜色
        for (VideoSegment segment in videoSegments) {
          // 计算视频段的分钟范围
          int startMinute = segment.startTime.hour * 60 + segment.startTime.minute;
          int endMinute = segment.endTime.hour * 60 + segment.endTime.minute;

          if (currentMinute >= startMinute && currentMinute <= endMinute) {
            if (segment.type == 'normal') {
              backgroundPaint.color = Colors.green;
            } else if (segment.type == 'ai') {
              backgroundPaint.color = Colors.greenAccent;
            }
            // 添加其他类型的颜色设置
            canvas.drawRect(
              Rect.fromPoints(Offset(x + segment.startTime.minute * minuteWidth, 0), Offset(minuteX, size.height)),
              backgroundPaint,
            );
          }
        }
      }

      // 绘制刻度线
      canvas.drawLine(Offset(x, 0), Offset(x, height * 0.7), linePaint);

      // 显示时间文本
      final String timeText = '$hour:00';
      textPainter.text = TextSpan(text: timeText, style: textStyle);
      textPainter.layout();
      textPainter.paint(canvas, Offset(x - textPainter.width / 2, height * 0.7));

      if (hour < 24) {
        // 绘制小刻度线
        for (int minute = 10; minute < 60; minute += 10) {
          final double minuteX = x + minute * minuteWidth;

          if (minute % 30 == 0) {
            canvas.drawLine(Offset(minuteX, height * 0.3), Offset(minuteX, height * 0.7), linePaint);
            // 显示分钟文本
            final String minuteText = '$hour:${minute.toString().padLeft(2, '0')}';
            textPainter.text = TextSpan(text: minuteText, style: textStyle);
            textPainter.layout();
            textPainter.paint(canvas, Offset(minuteX - textPainter.width / 2, height * 0.7));
          } else {
            canvas.drawLine(Offset(minuteX, height * 0.5), Offset(minuteX, height * 0.7), linePaint);
          }
        }
      }
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    //禁止其父级小部件的状态更改时重新绘制。
    // return false;
  if (oldDelegate is RulerPainter) {
      //videoSegments值需要回执的数据
      // 比较旧的和新的videoSegments值,以确定是否需要重绘。
   return !listEquals(videoSegments, oldDelegate.videoSegments);
  }
    return true; // 如果oldDelegate不是相同类型,始终重绘。
  }
}