Flutter - 原生交互 - 相机Camera - 曝光,缩放,录制视频

曝光

Flutter上CupertinoSlider组件的样式是iOS上的Slider,使用该组件控制曝光量, Camera插件提供的API是CameraController

dart 复制代码
Future<double> setExposureOffset(double offset) async {
...
}

最后调用iOS端的系统方法控制曝光值

objc 复制代码
- (void)setExposureTargetBias:(float)bias completionHandler:(nullable void (^)(CMTime syncTime))handler API_AVAILABLE(ios(8.0), macCatalyst(14.0), tvos(17.0)) API_UNAVAILABLE(macos, visionos);
dart 复制代码
class TakePictureScreenState extends State<TakePictureScreen> {
    /// 设置默认值
    double currentExposure = 0.0;
    /// 是否显示曝光Slider组件
    bool _showedExposure = false;
    ...
    /// 使用CupertinoSlider
    
    Widget showExposure() {
        if (_showedExposure) {
            return SizedBox(
                height: 44,
                width: MediaQuery.of(context).size.width,
                child: CupertinoSlider(
                    /// 滑动Slider时触发的事件
                    onChanged: (value) {
                        setState(() {
                            /// 调整相机的曝光值
                            _controller.setExposureOffset(value);
                            currentExposure = value;
                        });
                    },
                    min: -3, /// 设置作用范围
                    max: 3,
                    value: currentExposure, /// 当前Slider显示的值
                ),
            );
        }
    return SizedBox.shrink();
  }
}

两指手势缩放

系统的相机可以双指进行缩放操作,在Flutter中可以在GestureDetector来实现

dart 复制代码
/// 最小缩放比例
double _minAvailableZoom = 1.0;
/// 最大缩放比例
double _maxAvailableZoom = 1.0;
/// 记录当前的缩放比例
double _currentScale = 1.0;
/// 当前的基础值
double _baseScale = 1.0;

Listener(
    onPointerDown: (_) => _pointers++,
    onPointerUp: (_) => _pointers--, // 用来判断是否双指
    child: CameraPreview(
            _controller,
            // Creates a widget that defers its building until layout.
            // 布局完成再创建
            child: LayoutBuilder(
                builder: (BuildContext context, BoxConstraints constraints) {
                  return GestureDetector(
                            // Opaque targets can be hit by hit tests, causing them to both receive events within their bounds and prevent targets visually behind them from also receiving events.
                            // 相机Widget能收到手势
                            behavior: HitTestBehavior.opaque, 
                            // 设置开始缩放事件
                            onScaleStart: _handleScaleStart,
                            // 设置缩放值变化事件
                            onScaleUpdate: _handleScaleUpdate,
                            onTapDown:
                            (TapDownDetails details) =>
                                onViewFinderTap(details, constraints),
      );
    },
  ),
),
);

/// 开始缩放时用_baseScale记录当前的缩放值
void _handleScaleStart(ScaleStartDetails details) {
    _baseScale = _currentScale;
}

Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
    // 判断是否两指操作
    if (_pointers != 2) {
      return;
    }

    // 当前值 = 基础值 * 缩放的值,clamp方法限制缩放值的范围
    _currentScale = (_baseScale * details.scale).clamp(
        _minAvailableZoom,
        _maxAvailableZoom,
    );
    
    // 设置缩放
    await _controller.setZoomLevel(_currentScale);
}

录制视频

使用Camera组件中的cameraController.startVideoRecording()方法来开始拍摄视频,然后用cameraController.stopVideoRecording()方法可以结束视频。

dart 复制代码
onPressed: () async {
    await _initializeControllerFuture;
    /// 当前在拍照
    if (_isPhotoMode) {
        ...
    } else {
        // 通过camera组件提供的isRecordingVideo字段确认
        // 当前在拍摄视频
        if (_controller.value.isRecordingVideo) {
            onStopButtonPressed();
        } else {
            /// 开始拍摄
            onVideoRecordButtonPressed();
        }
    }
}

录制过程中显示时间

dart 复制代码
class TakePictureScreenState extends State<TakePictureScreen>
    with WidgetsBindingObserver {
    ...
Timer? _timer; // 计时器
int _totalSeconds = 0; // 总秒数

/// 格式化时间为 00:00
String _formatTime() {
    final minutes = (_totalSeconds ~/ 60).toString().padLeft(2, '0');
    final seconds = (_totalSeconds % 60).toString().padLeft(2, '0');
    return '$minutes:$seconds';
}

/// 定时器执行方法
void _startTimer() {
    _timer = Timer.periodic(Duration(seconds: 1), (timer) {
        setState(() {
            _totalSeconds++;
       });
    });
}

/// dispose方法中要释放timer
@override
void dispose() {
    _controller.dispose();
    _timer?.cancel();
    super.dispose();
}

/// 定时器组件
Widget timingWidget() {
    return Align(
      alignment: const Alignment(0.9, 0),
      child: Padding(
        padding: EdgeInsets.fromLTRB(0, 20, 10, 20),
        child: Text(
          _formatTime(),
          style: TextStyle(
            color: Colors.white,
            fontSize: 20,
            decoration: TextDecoration.none,
          ),
        ),
      ),
    );
  }
}

点击录制事件

dart 复制代码
void onVideoRecordButtonPressed() {
    startVideoRecording().then((_) {
      /// 异步调用完成后重新刷新页面,比如按钮变化了
      if (mounted) {
        _startTimer(); // 启动定时器
        setState(() {});
      }
    });
}

Future<void> startVideoRecording() async {
    final CameraController cameraController = _controller;

    if (!cameraController.value.isInitialized) {
      return;
    }

    if (cameraController.value.isRecordingVideo) {
      return;
    }

    try {
      await cameraController.startVideoRecording();
    } on CameraException catch (e) {
      debugPrint(e.toString());
      return;
    }
  }

结束录制

dart 复制代码
void onStopButtonPressed() {
    stopVideoRecording().then((XFile? file) {
      if (mounted) {
        // 停止计时器 && 重置计数值
        if (_timer?.isActive == true) {
          _timer?.cancel();
          _totalSeconds = 0;
        }
        setState(() {});
      }
      /// 获取拍摄的视频文件
      if (file != null) {
        /// 设置视频文件显示预览效果
        videoFile = file;
        _startVideoPlayer();
      }
    });
}
  
Future<XFile?> stopVideoRecording() async {
    final CameraController cameraController = _controller;

    if (!cameraController.value.isRecordingVideo) {
      return null;
    }

    try {
      return cameraController.stopVideoRecording();
    } on CameraException catch (e) {
      debugPrint(e.toString());
      return null;
    }
}

查看拍摄视频

添加video_player组件用于预览视频

sh 复制代码
¥ flutter pub add video_player
¥ flutter pub get

录制结束后调用_startVideoPlayer方法

dart 复制代码
Future<void> _startVideoPlayer() async {
    if (videoFile == null) {
      return;
    }
    
    /// Web则用网络加载
    final VideoPlayerController vController =
        kIsWeb
            ? VideoPlayerController.networkUrl(Uri.parse(videoFile!.path))
            : VideoPlayerController.file(File(videoFile!.path));

    /// 创建监听器刷新页面
    videoPlayerListener = () {
      if (videoController != null) {
        if (mounted) {
          setState(() {});
        }
        videoController!.removeListener(videoPlayerListener!);
      }
    };
    
    vController.addListener(videoPlayerListener!);
    /// 是否循环播放
    await vController.setLooping(true);
    await vController.initialize();
    /// 清除原来的
    await videoController?.dispose();
    if (mounted) {
      setState(() {
        imageFile = null;
        videoController = vController;
      });
    }
    /// 播放
    await vController.play();
}

预览图组件

dart 复制代码
Widget _thumbnailWidget() {
    final VideoPlayerController? localVideoController = videoController;
    bool isNoThumbnail = localVideoController == null && imageFile == null;
    return Align(
      alignment: Alignment(-0.8, 0),
      child: SizedBox(
        width: 64,
        height: 64,
        child:
            isNoThumbnail
                ? Container()
                : GestureDetector(
                ...
                child:
                      /// 当前是拍照的场景
                      (localVideoController == null)
                          ? (
                          kIsWeb
                              ? Image.network(imageFile!.path)
                              : Image.file(File(imageFile!.path)))
                          : AspectRatio(
                            /// 当前是视频的场景使用video_player插件提供的组件进行播放
                            aspectRatio: localVideoController.value.aspectRatio,
                            child: VideoPlayer(localVideoController),
                          ),
                )

因为是点击的是同一个按钮进行拍照和视频,一些元素的控制

dart 复制代码
/// 点击Video时清空imageFile

GestureDetector(
  child: Container(
    margin: EdgeInsets.fromLTRB(0, 8, 8, 0),
    child: Text(
      "VIDEO",
      style: TextStyle(
        color: _isPhotoMode ? Colors.white : Colors.orangeAccent,
        fontSize: 16,
        decoration: TextDecoration.none,
      ),
    ),
  ),
  onTap:
      () => {
        setState(() {
          _isPhotoMode = false;
          imageFile = null;
        }),
      },
),

/// 拍照时也清空视频相关的属性
if (_isPhotoMode) {
    final image = await _controller.takePicture();

    if (!context.mounted) return;
    setState(() {
        imageFile = image;
        videoController?.dispose();
        videoController = null;
    });
} else {
    // 点击开始/停止录制视频
    ...
}

问题

锁定相机方向,避免手机横屏时相机视图变化

使用CameraController对象的lockCaptureOrientation方法可以锁定相机的方向

dart 复制代码
...
if (snapshot.connectionState == ConnectionState.done) {
  // 相机初始化完成时,锁定相机的方向
  _controller.lockCaptureOrientation(
    DeviceOrientation.portraitUp,
  );
  return CameraPreview(_controller);
}

参考

  1. Fixing Stretched Camera Preview on Flutter Rotation
  2. Camera
  3. How to set Flutter CameraPreview Size "Fullscreen"
相关推荐
二流小码农6 小时前
鸿蒙开发:资讯项目实战之项目框架设计
android·ios·harmonyos
Android研究员8 小时前
HarmonyOS实战:List拖拽位置交换的多种实现方式
android·ios·harmonyos
恋猫de小郭8 小时前
Flutter 应该如何实现 iOS 26 的 Liquid Glass ,它为什么很难?
android·前端·flutter
杉木笙9 小时前
Flutter 代码雨实现(矩阵雨)DLC 爆炸粒子
flutter·视觉设计
YungFan11 小时前
Xcode26新特性与iOS26适配指南
ios·xcode
season_zhu13 小时前
RxSwift:这可能是Notification最优雅的封装方式之一了
ios·架构·rxswift
kymjs张涛13 小时前
前沿技术周刊 2025-06-16
前端·ios·github
RichardLai8814 小时前
[Flutter 进阶] - Flutter 与原生通讯 - 你了解多少?
android·前端·flutter
恋猫de小郭16 小时前
React Native 0.80 开始支持 iOS 预构建
android·前端·flutter