diff --git a/CHANGELOG.md b/CHANGELOG.md index abadfcb..9f7e046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ that can be found in the LICENSE file. --> # Changelog +See the [Migration Guide](guides/migration_guide.md) for the details of breaking changes between versions. + +## 4.0.0-dev.1 + +To know more about breaking changes, see [Migration Guide][]. + +### New features + +- Sync all UI details from WeChat 8.3.x. (#181) + +### Improvements + +- Improve the experience when using the exposure slider. +- Prefer `FlashMode.off` for better performance. +- Allow `cameras` to be set repeatedly. + +### Fixes + +- Fix accessibility on the switch cameras button. + ## 3.8.0 ### New features @@ -324,3 +344,5 @@ that can be found in the LICENSE file. --> - Support taking pictures and videos. - Support video recording duration limitation. + +[Migration Guide]: guides/migration_guide.md diff --git a/README-ZH.md b/README-ZH.md index 74d2a96..371ffc5 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -19,18 +19,31 @@ Language: [English](README.md) | 中文简体 [**仿微信资源选择器**](https://pub.flutter-io.cn/packages/wechat_assets_picker) 的扩展。 选择器基于 `camera` 实现相机相关功能,`photo_manager` 实现资源相关内容。 -## 目录 🗂 - -- [Flutter WeChat Camera Picker](#flutter-wechat-camera-picker) - - [目录 🗂](#目录-) - - [特性 ✨](#特性-) - - [截图 📸](#截图-) - - [准备工作 🍭](#准备工作-) - - [使用方法 📖](#使用方法-) - - [简单的使用方法](#简单的使用方法) - - [使用配置](#使用配置) - - [常见问题 💭](#常见问题-) - - [iOS 上的预览在旋转时行为诡异](#iOS-上的预览在旋转时行为诡异) +当前的界面设计基于的微信版本:**8.3.x** +界面更新将在微信版本更新后随时进行跟进。 + +查看 [迁移指南][] 了解如何从破坏性改动中迁移为可用代码。 + +
+ Table of content + + +* [Flutter WeChat Camera Picker](#flutter-wechat-camera-picker) + * [特性 ✨](#特性-) + * [截图 📸](#截图-) + * [准备工作 🍭](#准备工作-) + * [版本限制](#版本限制) + * [配置](#配置) + * [Android 13 (API 33) 权限配置](#android-13-api-33-权限配置) + * [使用方法 📖](#使用方法-) + * [简单的使用方法](#简单的使用方法) + * [使用配置](#使用配置) + * [简单的使用方法](#简单的使用方法-1) + * [使用自定义的 `State`](#使用自定义的-state) + * [常见问题 💭](#常见问题-) + * [iOS 上的预览在旋转时行为诡异](#ios-上的预览在旋转时行为诡异) + +
## 特性 ✨ @@ -164,3 +177,5 @@ final AssetEntity? entity = await CameraPicker.pickFromCamera( 你可以在这个 issue 里了解更多相关的信息: https://github.com/flutter/flutter/issues/89216 。 除此之外的问题,你可以提交 issue 进行提问。 + +[迁移指南]: https://github.com/fluttercandies/flutter_wechat_camera_picker/blob/main/guides/migration_guide.md diff --git a/README.md b/README.md index 1153cb8..91f182d 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,31 @@ A **camera picker** based on WeChat's UI which is a separate runnable extension The package based on `camera` for camera functions and `photo_manager` for asset implementation. -## Category 🗂 - -- [Flutter WeChat Camera Picker](#flutter-wechat-camera-picker) - - [Category 🗂](#category-) - - [Features ✨](#features-) - - [Screenshots 📸](#screenshots-) - - [Preparing for use 🍭](#preparing-for-use-) - - [Usage 📖](#usage-) - - [Simple usage](#simple-usage) - - [With configurations](#with-configurations) - - [Frequently asked question 💭](#frequently-asked-question-) - - [Why the orientation behavior is strange on iOS?](#why-the-orientation-behavior-is-strange-on-ios) - - [Contributors ✨](#contributors-) +Current WeChat version that UI based on: **8.3.x** +UI designs will be updated following the WeChat update in anytime. + +See the [Migration Guide][] to learn how to migrate between breaking changes. + +
+ Table of content + + +* [Flutter WeChat Camera Picker](#flutter-wechat-camera-picker) + * [Features ✨](#features-) + * [Screenshots 📸](#screenshots-) + * [Preparing for use 🍭](#preparing-for-use-) + * [Version constraints](#version-constraints) + * [Setup](#setup) + * [Android 13 (API 33) permissions](#android-13-api-33-permissions) + * [Usage 📖](#usage-) + * [Simple usage](#simple-usage) + * [With configurations](#with-configurations) + * [Using custom `State`s](#using-custom-states) + * [Frequently asked question 💭](#frequently-asked-question-) + * [Why the orientation behavior is strange on iOS?](#why-the-orientation-behavior-is-strange-on-ios) + * [Contributors ✨](#contributors-) + +
## Features ✨ @@ -181,3 +193,5 @@ Thank goes to these wonderful people ([emoji key](https://allcontributors.org/do This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! + +[Migration Guide]: https://github.com/fluttercandies/flutter_wechat_camera_picker/blob/main/guides/migration_guide.md diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 131a66f..e4fa0a2 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: wechat_camera_picker_demo description: A new Flutter project. -version: 3.8.0+23 +version: 4.0.0+24 publish_to: none environment: diff --git a/guides/migration_guide.md b/guides/migration_guide.md new file mode 100644 index 0000000..71bd17a --- /dev/null +++ b/guides/migration_guide.md @@ -0,0 +1,55 @@ + + +# Migration Guide + +This document gathered all breaking changes and migrations requirement between major versions. + +## Versions + +- [4.0.0](#400) + +## 4.0.0 + +### Summary + +> If you don't extend your customized `CameraPickerState` +> or you didn't override below methods, you can stop reading. + +In order to sync the UI details with the latest WeChat style (v8.3.0), +few naming or signatures of methods are changed. including: +- `restartDisplayModeDisplayTimer` +- `buildBackButton` +- `buildCameraPreview` +- `buildCaptureButton` +- `buildFocusingPoint` + +### Details + +- `restartDisplayModeDisplayTimer` is renamed to `restartExposureModeDisplayTimer`. +- `buildBackButton` no more requires `BoxConstraints constraints` as an argument, + the signature is `Widget buildBackButton(BuildContext context)` from now on. +- `buildCameraPreview` no more requires `DeviceOrientation orientation` as an argument + since the implementation does not really use it. + It now requires `CameraValue cameraValue` as an argument. So the signature becomes: + ```dart + Widget buildCameraPreview({ + required BuildContext context, + required CameraValue cameraValue, + required BoxConstraints constraints, + }) + ``` +- `buildCaptureButton` now requires `BuildContext context` as an argument. So the signature becomes: + ```dart + Widget buildCaptureButton(BuildContext context, BoxConstraints constraints) + ``` +- `buildFocusingPoint` now adds `int quarterTurns` to make internal quarter turns. + So the signature becomes: + ```dart + Widget buildFocusingPoint({ + required CameraValue cameraValue, + required BoxConstraints constraints, + int quarterTurns = 0, + }) + ``` diff --git a/lib/src/constants/config.dart b/lib/src/constants/config.dart index f75f116..586c429 100644 --- a/lib/src/constants/config.dart +++ b/lib/src/constants/config.dart @@ -51,8 +51,8 @@ class CameraPickerConfig { /// 选择器是否可以录像 final bool enableRecording; - /// Whether the picker can record video. - /// 选择器是否可以录像 + /// Whether the picker can record video only. + /// 选择器是否只可以录像 final bool onlyEnableRecording; /// Whether allow the record can start with single tap. diff --git a/lib/src/states/camera_picker_state.dart b/lib/src/states/camera_picker_state.dart index 18c7e7c..0021769 100644 --- a/lib/src/states/camera_picker_state.dart +++ b/lib/src/states/camera_picker_state.dart @@ -25,7 +25,7 @@ import '../widgets/camera_picker.dart'; import '../widgets/camera_picker_viewer.dart'; import '../widgets/camera_progress_button.dart'; -const Color _lockedColor = Colors.amber; +const Color _lockedColor = Colors.orangeAccent; const Duration _kDuration = Duration(milliseconds: 300); class CameraPickerState extends State @@ -45,6 +45,7 @@ class CameraPickerState extends State /// Whether the focus point is displaying. /// 是否正在展示当前的聚焦点 final ValueNotifier isFocusPointDisplays = ValueNotifier(false); + final ValueNotifier isFocusPointFadeOut = ValueNotifier(false); /// The controller for the current camera. /// 当前相机实例的控制器 @@ -58,6 +59,8 @@ class CameraPickerState extends State /// Current exposure offset. /// 当前曝光值 final ValueNotifier currentExposureOffset = ValueNotifier(0); + final ValueNotifier currentExposureSliderOffset = + ValueNotifier(0); double maxAvailableExposureOffset = 0; double minAvailableExposureOffset = 0; @@ -92,8 +95,8 @@ class CameraPickerState extends State /// The [Timer] for keep the [lastExposurePoint] displays. /// 用于控制上次手动聚焦点显示的计时器 Timer? exposurePointDisplayTimer; - Timer? exposureModeDisplayTimer; + Timer? exposureFadeOutTimer; /// The [Timer] for record start detection. /// 用于检测是否开始录制的计时器 @@ -130,11 +133,21 @@ class CameraPickerState extends State //////////////////////////////////////////////////////////////////////////// CameraPickerConfig get pickerConfig => widget.pickerConfig; + /// Whether the camera preview should be scaled during captures. + /// 拍摄过程中相机预览是否需要缩放 + bool get enableScaledPreview => pickerConfig.enableScaledPreview; + + /// Whether the picker can record video. + /// 选择器是否可以录像 bool get enableRecording => pickerConfig.enableRecording; + /// Whether the picker only enables video recording. + /// 选择器是否只可以录像 bool get onlyEnableRecording => enableRecording && pickerConfig.onlyEnableRecording; + /// Whether allow the record can start with single tap. + /// 选择器是否可以单击录像 bool get enableTapRecording => onlyEnableRecording && pickerConfig.enableTapRecording; @@ -169,6 +182,11 @@ class CameraPickerState extends State return pickerConfig.minimumRecordingDuration; } + /// Whether the camera preview should be rotated. + bool get isCameraRotated => pickerConfig.cameraQuarterTurns % 4 != 0; + + int get cameraQuarterTurns => pickerConfig.cameraQuarterTurns; + /// A getter to the current [CameraDescription]. /// 获取当前相机实例 CameraDescription get currentCamera => cameras.elementAt(currentCameraIndex); @@ -194,10 +212,13 @@ class CameraPickerState extends State ambiguate(WidgetsBinding.instance)?.removeObserver(this); innerController?.dispose(); currentExposureOffset.dispose(); + currentExposureSliderOffset.dispose(); lastExposurePoint.dispose(); isFocusPointDisplays.dispose(); + isFocusPointFadeOut.dispose(); exposurePointDisplayTimer?.cancel(); exposureModeDisplayTimer?.cancel(); + exposureFadeOutTimer?.cancel(); recordDetectTimer?.cancel(); recordCountdownTimer?.cancel(); super.dispose(); @@ -223,7 +244,7 @@ class CameraPickerState extends State BoxConstraints constraints, CameraController controller, ) { - final int turns = pickerConfig.cameraQuarterTurns; + final int turns = cameraQuarterTurns; final String orientation = controller.value.deviceOrientation.toString(); // Fetch the biggest size from the constraints. Size size = constraints.biggest; @@ -258,12 +279,14 @@ class CameraPickerState extends State currentZoom = 1; baseZoom = 1; // Meanwhile, cancel the existed exposure point and mode display. - exposureModeDisplayTimer?.cancel(); exposurePointDisplayTimer?.cancel(); + exposureModeDisplayTimer?.cancel(); + exposureFadeOutTimer?.cancel(); + isFocusPointDisplays.value = false; + isFocusPointFadeOut.value = false; lastExposurePoint.value = null; - if (currentExposureOffset.value != 0) { - currentExposureOffset.value = 0; - } + currentExposureOffset.value = 0; + currentExposureSliderOffset.value = 0; }); // **IMPORTANT**: Push methods into a post frame callback, which ensures the // controller has already unbind from widgets. @@ -480,13 +503,21 @@ class CameraPickerState extends State }); } - void restartDisplayModeDisplayTimer() { + void restartExposureModeDisplayTimer() { exposureModeDisplayTimer?.cancel(); exposureModeDisplayTimer = Timer(const Duration(seconds: 2), () { isFocusPointDisplays.value = false; }); } + void restartExposureFadeOutTimer() { + isFocusPointFadeOut.value = false; + exposureFadeOutTimer?.cancel(); + exposureFadeOutTimer = Timer(const Duration(seconds: 2), () { + isFocusPointFadeOut.value = true; + }); + } + /// Use the specific [mode] to update the exposure mode. /// 设置曝光模式 Future switchExposureMode() async { @@ -508,7 +539,8 @@ class CameraPickerState extends State } catch (e, s) { handleErrorWithHandler(e, pickerConfig.onError, s: s); } - restartDisplayModeDisplayTimer(); + restartExposureModeDisplayTimer(); + restartExposureFadeOutTimer(); } /// Use the [position] to set exposure and focus. @@ -518,11 +550,13 @@ class CameraPickerState extends State BoxConstraints constraints, ) async { isFocusPointDisplays.value = false; - // Ignore point update when the new point is less than 8% and higher than - // 92% of the screen's height. - if (position.dy < constraints.maxHeight / 12 || - position.dy > constraints.maxHeight / 12 * 11) { - return; + if (enableScaledPreview) { + // Ignore point update when the new point is less than 8% and higher than + // 92% of the screen's height. + if (position.dy < constraints.maxHeight / 12 || + position.dy > constraints.maxHeight / 12 * 11) { + return; + } } realDebugPrint( 'Setting new exposure point (x: ${position.dx}, y: ${position.dy})', @@ -530,20 +564,25 @@ class CameraPickerState extends State lastExposurePoint.value = position; restartExposurePointDisplayTimer(); currentExposureOffset.value = 0; + currentExposureSliderOffset.value = 0; + restartExposureFadeOutTimer(); + isFocusPointFadeOut.value = false; try { - if (controller.value.exposureMode == ExposureMode.locked) { - await controller.setExposureMode(ExposureMode.auto); - } + await Future.wait(>[ + controller.setExposureOffset(0), + if (controller.value.exposureMode == ExposureMode.locked) + controller.setExposureMode(ExposureMode.auto), + ]); final Offset newPoint = lastExposurePoint.value!.scale( 1 / constraints.maxWidth, 1 / constraints.maxHeight, ); - if (controller.value.exposurePointSupported) { - controller.setExposurePoint(newPoint); - } - if (controller.value.focusPointSupported) { - controller.setFocusPoint(newPoint); - } + await Future.wait(>[ + if (controller.value.exposurePointSupported) + controller.setExposurePoint(newPoint), + if (controller.value.focusPointSupported) + controller.setFocusPoint(newPoint), + ]); } catch (e, s) { handleErrorWithHandler(e, pickerConfig.onError, s: s); } @@ -552,6 +591,7 @@ class CameraPickerState extends State /// Update the exposure offset using the exposure controller. /// 使用曝光控制器更新曝光值 Future updateExposureOffset(double value) async { + currentExposureSliderOffset.value = value; // Normalize the new exposure value if exposures have steps. if (exposureStep > 0) { final double inv = 1.0 / exposureStep; @@ -570,6 +610,7 @@ class CameraPickerState extends State } currentExposureOffset.value = value; try { + realDebugPrint('Updating the exposure offset value: $value'); // Use [CameraPlatform] explicitly to reduce channel calls. await CameraPlatform.instance.setExposureOffset( controller.cameraId, @@ -581,8 +622,27 @@ class CameraPickerState extends State if (!isFocusPointDisplays.value) { isFocusPointDisplays.value = true; } - restartDisplayModeDisplayTimer(); restartExposurePointDisplayTimer(); + restartExposureModeDisplayTimer(); + restartExposureFadeOutTimer(); + } + + /// Request to set the focus and the exposure point on the [localPosition], + /// [lock] to lock the exposure mode at the same time. + /// 将对焦和曝光设置为给定的点 [localPosition],[lock] 控制是否同时锁定曝光模式。 + Future requestFocusAndExposureOnPosition( + Offset localPosition, + BoxConstraints constraints, { + bool lock = false, + }) async { + // Only call exposure point updates when the controller is initialized. + if (innerController?.value.isInitialized ?? false) { + Feedback.forTap(context); + await setExposureAndFocusPoint(localPosition, constraints); + if (lock) { + await switchExposureMode(); + } + } } /// Update the scale value while the user is shooting. @@ -865,13 +925,25 @@ class CameraPickerState extends State if (v.isRecordingVideo) { return const SizedBox.shrink(); } + Widget backButton = buildBackButton(context); + Widget flashModeSwitch = buildFlashModeSwitch(context, v); + if (isCameraRotated && !enableScaledPreview) { + backButton = RotatedBox( + quarterTurns: cameraQuarterTurns, + child: backButton, + ); + flashModeSwitch = RotatedBox( + quarterTurns: cameraQuarterTurns, + child: flashModeSwitch, + ); + } return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - if (cameras.length > 1) buildCameraSwitch(context), + if (innerController?.value.isRecordingVideo != true) backButton, const Spacer(), - buildFlashModeSwitch(context, v), + flashModeSwitch, ], ), ); @@ -882,16 +954,18 @@ class CameraPickerState extends State /// The button to switch between cameras. /// 切换相机的按钮 Widget buildCameraSwitch(BuildContext context) { - return IconButton( - tooltip: textDelegate.sSwitchCameraLensDirectionLabel( - nextCameraDescription.lensDirection, - ), - onPressed: switchCameras, - icon: Icon( - Platform.isIOS - ? Icons.flip_camera_ios_outlined - : Icons.flip_camera_android_outlined, - size: 24, + return MergeSemantics( + child: IconButton( + tooltip: textDelegate.sSwitchCameraLensDirectionLabel( + nextCameraDescription.lensDirection, + ), + onPressed: () => switchCameras(), + icon: Icon( + Platform.isIOS + ? Icons.flip_camera_ios_outlined + : Icons.flip_camera_android_outlined, + size: 24, + ), ), ); } @@ -943,7 +1017,11 @@ class CameraPickerState extends State opacity: controller?.value.isRecordingVideo ?? false ? 0 : 1, child: Padding( padding: const EdgeInsets.all(20), - child: Text(tips, style: const TextStyle(fontSize: 15)), + child: Text( + tips, + style: const TextStyle(fontSize: 15), + textAlign: TextAlign.center, + ), ), ); } @@ -962,64 +1040,61 @@ class CameraPickerState extends State height: 118, child: Row( children: [ - if (controller?.value.isRecordingVideo != true) - Expanded(child: buildBackButton(context, constraints)) - else - const Spacer(), + const Spacer(), Expanded( child: Center( - child: MergeSemantics(child: buildCaptureButton(constraints)), + child: buildCaptureButton(context, constraints), ), ), - const Spacer(), + if (innerController != null && cameras.length > 1) + Expanded( + child: RotatedBox( + quarterTurns: !enableScaledPreview ? cameraQuarterTurns : 0, + child: buildCameraSwitch(context), + ), + ) + else + const Spacer(), ], ), ); } - /// The back button near to the [buildCaptureButton]. - /// 靠近拍照键的返回键 - Widget buildBackButton(BuildContext context, BoxConstraints constraints) { + /// The back button. + /// 返回键 + Widget buildBackButton(BuildContext context) { return IconButton( - onPressed: Navigator.of(context).pop, + onPressed: () => Navigator.of(context).maybePop(), tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - alignment: Alignment.center, - width: 27, - height: 27, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: const Icon(Icons.keyboard_arrow_down, color: Colors.black), - ), + icon: const Icon(Icons.clear), ); } /// The shooting button. /// 拍照按钮 - Widget buildCaptureButton(BoxConstraints constraints) { + Widget buildCaptureButton(BuildContext context, BoxConstraints constraints) { const Size outerSize = Size.square(115); const Size innerSize = Size.square(82); - return Semantics( - label: textDelegate.sActionShootingButtonTooltip, - onTap: onTap, - onTapHint: onTapHint, - onLongPress: onLongPress, - onLongPressHint: onLongPressHint, - child: Listener( - behavior: HitTestBehavior.opaque, - onPointerUp: onPointerUp, - onPointerMove: onPointerMove(constraints), - child: GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: SizedBox.fromSize( - size: outerSize, - child: Stack( - children: [ - Center( - child: AnimatedContainer( + return MergeSemantics( + child: Semantics( + label: textDelegate.sActionShootingButtonTooltip, + onTap: onTap, + onTapHint: onTapHint, + onLongPress: onLongPress, + onLongPressHint: onLongPressHint, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerUp: onPointerUp, + onPointerMove: onPointerMove(constraints), + child: GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox.fromSize( + size: outerSize, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedContainer( duration: kThemeChangeDuration, width: isShootingButtonAnimate ? outerSize.width @@ -1029,7 +1104,7 @@ class CameraPickerState extends State : innerSize.height, padding: EdgeInsets.all(isShootingButtonAnimate ? 41 : 11), decoration: BoxDecoration( - color: theme.canvasColor.withOpacity(0.85), + color: Theme.of(context).canvasColor.withOpacity(0.85), shape: BoxShape.circle, ), child: const DecoratedBox( @@ -1039,17 +1114,21 @@ class CameraPickerState extends State ), ), ), - ), - if ((innerController?.value.isRecordingVideo ?? false) && - isRecordingRestricted) - CameraProgressButton( - isAnimating: isShootingButtonAnimate, - duration: pickerConfig.maximumRecordingDuration!, - outerRadius: outerSize.width, - ringsColor: theme.indicatorColor, - ringsWidth: 2, - ), - ], + if ((innerController?.value.isRecordingVideo ?? false) && + isRecordingRestricted) + RotatedBox( + quarterTurns: + !enableScaledPreview ? cameraQuarterTurns : 0, + child: CameraProgressButton( + isAnimating: isShootingButtonAnimate, + duration: pickerConfig.maximumRecordingDuration!, + outerRadius: outerSize.width, + ringsColor: theme.indicatorColor, + ringsWidth: 2, + ), + ), + ], + ), ), ), ), @@ -1072,26 +1151,25 @@ class CameraPickerState extends State opacity: value ? 1 : 0, child: child, ), - child: Center(child: Container(width: 1, color: color)), + child: Center(child: Container(width: 1.5, color: color)), ); return ValueListenableBuilder( - valueListenable: currentExposureOffset, + valueListenable: currentExposureSliderOffset, builder: (_, double exposure, __) { - final double effectiveTop = (size + gap) + + final double topByCurrentExposure = (minAvailableExposureOffset.abs() - exposure) * (height - size * 3) / (maxAvailableExposureOffset - minAvailableExposureOffset); - final double effectiveBottom = height - effectiveTop - size; + final double lineTop = size + topByCurrentExposure; + final double lineBottom = height - lineTop - size; return Stack( clipBehavior: Clip.none, children: [ - Positioned.fill(top: effectiveTop + gap, child: lineWidget), - Positioned.fill(bottom: effectiveBottom + gap, child: lineWidget), + Positioned.fill(top: lineTop + gap, child: lineWidget), + Positioned.fill(bottom: lineBottom + gap, child: lineWidget), Positioned( - top: (minAvailableExposureOffset.abs() - exposure) * - (height - size * 3) / - (maxAvailableExposureOffset - minAvailableExposureOffset), + top: topByCurrentExposure - gap, child: Transform.rotate( angle: exposure, child: Icon(Icons.wb_sunny_outlined, size: size, color: color), @@ -1127,6 +1205,7 @@ class CameraPickerState extends State Widget buildFocusingPoint({ required CameraValue cameraValue, required BoxConstraints constraints, + int quarterTurns = 0, }) { Widget buildControls(double size, double height) { const double verticalGap = 3; @@ -1171,41 +1250,78 @@ class CameraPickerState extends State Widget buildFromPoint(Offset point) { const double controllerWidth = 20; final double pointWidth = constraints.maxWidth / 5; + final double lineHeight = pointWidth * 2.5; final double exposureControlWidth = pickerConfig.enableExposureControlOnPoint ? controllerWidth : 0; final double width = pointWidth + exposureControlWidth + 2; - final bool shouldReverseLayout = point.dx > constraints.maxWidth / 4 * 3; - final double effectiveLeft = math.min( - constraints.maxWidth - width, - math.max(0, point.dx - width / 2), - ); - final double effectiveTop = math.min( - constraints.maxHeight - pointWidth * 3, - math.max(0, point.dy - pointWidth * 3 / 2), - ); + final bool shouldReverseLayout = cameraQuarterTurns.isEven && + enableScaledPreview && + point.dx > constraints.maxWidth / 4 * 3; + final double effectiveLeft, effectiveTop, effectiveWidth, effectiveHeight; + if (cameraQuarterTurns.isOdd && !enableScaledPreview) { + effectiveLeft = math.min( + constraints.maxWidth - lineHeight, + math.max(0, point.dx - lineHeight / 2), + ); + effectiveTop = math.min( + constraints.maxHeight - width, + math.max(0, point.dy - width / 2), + ); + effectiveWidth = lineHeight; + effectiveHeight = width; + } else { + effectiveLeft = math.min( + constraints.maxWidth - width, + math.max(0, point.dx - width / 2), + ); + effectiveTop = math.min( + constraints.maxHeight - lineHeight, + math.max(0, point.dy - lineHeight / 2), + ); + effectiveWidth = width; + effectiveHeight = lineHeight; + } return Positioned( left: effectiveLeft, top: effectiveTop, - width: width, - height: pointWidth * 3, + width: effectiveWidth, + height: effectiveHeight, child: ExcludeSemantics( - child: Row( - textDirection: - shouldReverseLayout ? TextDirection.rtl : TextDirection.ltr, - children: [ - CameraFocusPoint( - key: ValueKey(DateTime.now().millisecondsSinceEpoch), - size: pointWidth, - color: theme.iconTheme.color!, - ), - if (pickerConfig.enableExposureControlOnPoint) - const SizedBox(width: 2), - if (pickerConfig.enableExposureControlOnPoint) - SizedBox.fromSize( - size: Size(exposureControlWidth, pointWidth * 3), - child: buildControls(controllerWidth, pointWidth * 3), + child: ValueListenableBuilder( + valueListenable: isFocusPointFadeOut, + builder: (BuildContext context, bool isFadeOut, Widget? child) { + Widget body = AnimatedOpacity( + curve: Curves.ease, + duration: _kDuration, + opacity: isFadeOut ? .5 : 1, + child: Row( + textDirection: shouldReverseLayout + ? TextDirection.rtl + : TextDirection.ltr, + children: [ + child!, + if (pickerConfig.enableExposureControlOnPoint) + const SizedBox(width: 2), + if (pickerConfig.enableExposureControlOnPoint) + SizedBox.fromSize( + size: Size(exposureControlWidth, lineHeight), + child: buildControls(controllerWidth, lineHeight), + ), + ], ), - ], + ); + if (quarterTurns != 0) { + body = RotatedBox(quarterTurns: quarterTurns, child: body); + } + return body; + }, + child: CameraFocusPoint( + key: ValueKey(point), + size: pointWidth, + color: cameraValue.exposureMode == ExposureMode.locked + ? _lockedColor + : theme.iconTheme.color!, + ), ), ), ); @@ -1228,45 +1344,47 @@ class CameraPickerState extends State BuildContext context, BoxConstraints constraints, ) { - void focus(TapUpDetails d) { - // Only call exposure point updates when the controller is initialized. - if (innerController?.value.isInitialized ?? false) { - Feedback.forTap(context); - setExposureAndFocusPoint(d.localPosition, constraints); - } - } - - return Positioned.fill( - child: Semantics( - label: textDelegate.sCameraPreviewLabel( - innerController?.description.lensDirection, - ), - image: true, - onTap: () { - // Focus on the center point when using semantics tap. - final Size size = MediaQuery.of(context).size; - final TapUpDetails details = TapUpDetails( - kind: PointerDeviceKind.touch, - globalPosition: Offset(size.width / 2, size.height / 2), + return Semantics( + label: textDelegate.sCameraPreviewLabel( + innerController?.description.lensDirection, + ), + image: true, + onTap: () { + // Focus on the center point when using semantics tap. + final Size size = MediaQuery.of(context).size; + final TapUpDetails details = TapUpDetails( + kind: PointerDeviceKind.touch, + globalPosition: Offset(size.width / 2, size.height / 2), + ); + requestFocusAndExposureOnPosition(details.localPosition, constraints); + }, + onTapHint: textDelegate.sActionManuallyFocusHint, + sortKey: const OrdinalSortKey(1), + hidden: innerController == null, + excludeSemantics: true, + child: GestureDetector( + onTapUp: (TapUpDetails d) { + requestFocusAndExposureOnPosition( + d.localPosition, + constraints, ); - focus(details); }, - onTapHint: textDelegate.sActionManuallyFocusHint, - sortKey: const OrdinalSortKey(1), - hidden: innerController == null, - excludeSemantics: true, - child: GestureDetector( - onTapUp: focus, - behavior: HitTestBehavior.translucent, - child: const SizedBox.expand(), - ), + onLongPressStart: (LongPressStartDetails d) { + requestFocusAndExposureOnPosition( + d.localPosition, + constraints, + lock: true, + ); + }, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), ), ); } Widget buildCameraPreview({ required BuildContext context, - required DeviceOrientation orientation, + required CameraValue cameraValue, required BoxConstraints constraints, }) { Widget preview = Listener( @@ -1291,20 +1409,51 @@ class CameraPickerState extends State controller, preview, ); - preview = Center(child: transformedWidget ?? preview); + if (!enableScaledPreview) { + preview = Stack( + children: [ + preview, + Positioned.fill( + child: ExcludeSemantics( + child: RotatedBox( + quarterTurns: cameraQuarterTurns, + child: Align( + alignment: Alignment.bottomCenter, + child: buildCaptureTips(innerController), + ), + ), + ), + ), + if (pickerConfig.enableSetExposure) + buildExposureDetector(context, constraints), + buildFocusingPoint( + cameraValue: cameraValue, + constraints: constraints, + quarterTurns: cameraQuarterTurns, + ), + if (pickerConfig.foregroundBuilder != null) + Positioned.fill( + child: pickerConfig.foregroundBuilder!( + context, + innerController, + ), + ), + ], + ); + } // Scale the preview if the config is enabled. - if (pickerConfig.enableScaledPreview) { + if (enableScaledPreview) { preview = Transform.scale( scale: effectiveCameraScale(constraints, controller), - child: preview, - ); - } - // Rotated the preview if the turns is valid. - if (pickerConfig.cameraQuarterTurns % 4 != 0) { - preview = RotatedBox( - quarterTurns: -pickerConfig.cameraQuarterTurns, - child: preview, + child: Center(child: transformedWidget ?? preview), ); + // Rotated the preview if the turns is valid. + if (isCameraRotated) { + preview = RotatedBox( + quarterTurns: -cameraQuarterTurns, + child: preview, + ); + } } return RepaintBoundary(child: preview); } @@ -1341,7 +1490,8 @@ class CameraPickerState extends State child: buildSettingActions(context), ), const Spacer(), - ExcludeSemantics(child: buildCaptureTips(innerController)), + if (enableScaledPreview) + ExcludeSemantics(child: buildCaptureTips(innerController)), Semantics( sortKey: const OrdinalSortKey(2), hidden: innerController == null, @@ -1359,57 +1509,111 @@ class CameraPickerState extends State Widget buildBody(BuildContext context) { return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) => Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - ExcludeSemantics( - child: buildInitializeWrapper( - builder: (CameraValue v, Widget? w) => buildCameraPreview( - context: context, - orientation: v.deviceOrientation, - constraints: constraints, - ), - ), - ), - if (pickerConfig.enableSetExposure) - buildExposureDetector(context, constraints), - buildInitializeWrapper( - builder: (CameraValue v, _) => buildFocusingPoint( - cameraValue: v, - constraints: constraints, - ), + builder: (BuildContext context, BoxConstraints constraints) { + Widget previewWidget = ExcludeSemantics( + child: buildInitializeWrapper( + builder: (CameraValue v, Widget? w) { + if (enableScaledPreview) { + return buildCameraPreview( + context: context, + cameraValue: v, + constraints: constraints, + ); + } + return Align( + alignment: AlignmentDirectional.topCenter, + child: AspectRatio( + aspectRatio: 1 / v.aspectRatio, + child: LayoutBuilder( + builder: (BuildContext c, BoxConstraints constraints) { + return buildCameraPreview( + context: c, + cameraValue: v, + constraints: constraints, + ); + }, + ), + ), + ); + }, ), - buildForegroundBody(context, constraints), - if (pickerConfig.foregroundBuilder != null) - Positioned.fill( - child: pickerConfig.foregroundBuilder!(context, innerController), + ); + if (!enableScaledPreview) { + previewWidget = Semantics( + label: textDelegate.sCameraPreviewLabel( + innerController?.description.lensDirection, ), - ], - ), + image: true, + onTap: () { + // Focus on the center point when using semantics tap. + final Size size = MediaQuery.of(context).size; + final TapUpDetails details = TapUpDetails( + kind: PointerDeviceKind.touch, + globalPosition: Offset(size.width / 2, size.height / 2), + ); + requestFocusAndExposureOnPosition( + details.localPosition, + constraints, + ); + }, + onTapHint: textDelegate.sActionManuallyFocusHint, + sortKey: const OrdinalSortKey(1), + hidden: innerController == null, + excludeSemantics: true, + child: previewWidget, + ); + } + return Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + previewWidget, + if (enableScaledPreview) ...[ + if (pickerConfig.enableSetExposure) + buildExposureDetector(context, constraints), + buildInitializeWrapper( + builder: (CameraValue v, _) => buildFocusingPoint( + cameraValue: v, + constraints: constraints, + ), + ), + if (pickerConfig.foregroundBuilder != null) + Positioned.fill( + child: + pickerConfig.foregroundBuilder!(context, innerController), + ), + ], + buildForegroundBody(context, constraints), + ], + ); + }, ); } @override Widget build(BuildContext context) { - final MediaQueryData mq = MediaQuery.of(context); + Widget body = Builder(builder: buildBody); + if (isCameraRotated && enableScaledPreview) { + final MediaQueryData mq = MediaQuery.of(context); + body = RotatedBox( + quarterTurns: pickerConfig.cameraQuarterTurns, + child: MediaQuery( + data: mq.copyWith( + size: pickerConfig.cameraQuarterTurns.isOdd + ? mq.size.flipped + : mq.size, + ), + child: body, + ), + ); + } return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Theme( data: theme, child: Material( color: Colors.black, - child: RotatedBox( - quarterTurns: pickerConfig.cameraQuarterTurns, - child: MediaQuery( - data: mq.copyWith( - size: pickerConfig.cameraQuarterTurns.isOdd - ? mq.size.flipped - : mq.size, - ), - child: Builder(builder: buildBody), - ), - ), + child: body, ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 5d9a397..f3e5131 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: wechat_camera_picker description: A camera picker based on WeChat's UI which is a separate runnable extension to wechat_assets_picker. repository: https://github.com/fluttercandies/flutter_wechat_camera_picker -version: 3.8.0 +version: 4.0.0-dev.1 environment: sdk: ">=2.15.0 <3.0.0"