Flutter WeChat 风格相机与相册选择器
在 Flutter 开发中,常常需要实现图片/视频选择功能,比如从相册选择图片、拍照上传等。wechat_assets_picker 和 wechat_camera_picker 是优秀的第三方库,提供了类似微信的 UI 和交互体验,本文将详细讲解如何在 Flutter 项目中集成这两个库,并处理相关权限问题。
1. 简介
wechat_assets_picker
和 wechat_camera_picker
是基于微信 UI 实现的 Flutter 库,可用于图片选择和拍照/录像,界面美观且易于集成。
支持平台: Android、iOS
功能特点:
- 拍照 / 录制视频 / 从图库选择资源
- 资源类型设置(图片、视频或两者兼有)
- 最大选择数量设置(默认 9)
- 最大视频录制时长设置(默认 15 秒)
- 网格布局每行显示数量(默认 3)
- 图片 / 视频全屏查看
- 主题色随系统切换
2. 依赖库引入📦
# 权限申请
permission_handler: ^11.3.1
# 相册选择器
wechat_assets_picker: ^9.2.1
# 相机拍摄器
wechat_camera_picker: ^4.3.2
然后运行:
flutter pub get
3. 权限处理
Android
在 AndroidManifest.xml
中添加权限:
<!-- 拍照权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 闪光灯 -->
<uses-permission android:name="android.permission.FLASHLIGHT" />
<!-- 读写权限 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<activity android:exported="true" android:requestLegacyExternalStorage="true" />
注意: 如果 targetSdkVersion
>= 31(Android 12),需要设置 android:exported="true"
。
iOS
在 Info.plist
添加权限描述:
<key>NSCameraUsageDescription</key>
<string>是否允许 "APP" 使用您的相机,以便拍照</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>是否允许 "APP" 访问您的相册,以便保存图片</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>是否允许 "APP" 访问您的相册,以便上传图片</string>
<key>NSMicrophoneUsageDescription</key>
<string>是否允许 "APP" 使用您的麦克风,以便录制视频</string>
特别注意:
此外,在 Podfile
添加权限宏,否则 iOS 可能不会弹出权限请求框:(这是一个非常坑的地方)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', 'PERMISSION_CAMERA=1', 'PERMISSION_MICROPHONE=1','PERMISSION_PHOTOS=1']
end
4. 使用示例
注:包含引导弹窗和权限不足时跳转到权限设置页
4.1 打开相机
void onTapCamera() async {
try {
if (!(await checkCameraPermissions())) return;
if (!(await checkPhotosPermissions())) return;
final AssetEntity? entity = await CameraPicker.pickFromCamera(
Get.context!,
pickerConfig: const CameraPickerConfig(
enableAudio: false,
enableRecording: false,
resolutionPreset: ResolutionPreset.max,
),
);
if (entity == null) return;
final file = await entity.file;
if (file == null) throw Exception("无法获取文件");
final String filePath = file.path;
LogUtil.info("捕获的图片路径: $filePath");
} catch (e) {
LogUtil.error("拍照出错: $e");
}
}
4.2 打开相册
void onTapAlbum() async {
try {
if (!(await checkPhotosPermissions())) return;
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
Get.context!,
pickerConfig: AssetPickerConfig(
maxAssets: 9,
requestType: RequestType.image,
),
);
if (assets != null) {
for (var asset in assets) {
final String? filePath = await asset.file.then((file) => file?.path);
if (filePath != null) {
LogUtil.info("选择的图片路径: $filePath");
}
}
}
} catch (e) {
LogUtil.error("打开相册出错: $e");
}
}
5. 权限检查与处理
5.1 检查相机权限
Future<bool> checkCameraPermissions() async {
if (!(await Permission.camera.request().isGranted)) {
await showPermissionDeniedDialog("相机权限不足");
return false;
}
return true;
}
5.2 检查相册权限
Future<bool> checkPhotosPermissions() async {
if (!(await PhotoManager.requestPermissionExtend()).isAuthorized) {
await showPermissionDeniedDialog("相册权限不足");
return false;
}
return true;
}
5.3 权限不足提示弹窗
Future<void> showPermissionDeniedDialog(String message) async {
await Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("权限不足", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 16),
Text(message, textAlign: TextAlign.center),
SizedBox(height: 24),
ElevatedButton(
onPressed: () {
Get.back();
PhotoManager.openSetting();
},
child: Text("前往设置"),
),
],
),
),
),
barrierDismissible: false,
);
}
6.macOS需要使用文件选择器来选择文件。
文件选择器
file_picker: ^8.1.0
// macos使用文件选择器获取图片
if (Platform.isMacOS) {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
);
if (result != null && result.files.isNotEmpty) {
for (var file in result.files) {
final String? filePath = file.path;
if (filePath != null) {
LogUtil.info("Image path: $filePath");
var message = generatePictureMessage(curChatID.value, filePath);
var msgData = displayMessage(
indexController.selectedGroup.value.groupType, message);
sendUploadPicture(filePath, msgData);
}
}
}
return;
}
6. 结语
通过 wechat_assets_picker
和 wechat_camera_picker
,可以轻松实现微信风格的相机与相册选择功能,同时支持权限检查和异常处理,保证应用的稳定性与用户体验。
完整代码示例如下:
// 打开相机
void onTapCamera() async {
try {
// 检查相机权限
if (!(await checkCameraPermissions())) {
return; // 相机权限不足,直接返回
}
// 检查相册权限
if (!(await checkPhotosPermissions())) {
return; // 相册权限不足,直接返回
}
// 权限通过,执行拍照逻辑
final AssetEntity? entity = await CameraPicker.pickFromCamera(
Get.context!,
locale: Get.locale,
pickerConfig: const CameraPickerConfig(
enableAudio: false,
enableRecording: false,
onlyEnableRecording: false,
resolutionPreset: ResolutionPreset.max, //最大分辨率
),
);
if (entity == null) {
LogUtil.info("No photo captured.");
return;
}
final file = await entity.file;
if (file == null) {
throw Exception("Failed to fetch file from entity.");
}
final String filePath = file.path;
LogUtil.info("Captured image path: $filePath");
var message = generatePictureMessage(curChatID.value, filePath);
var msgData = displayMessage(
indexController.selectedGroup.value.groupType, message);
sendUploadPicture(filePath, msgData);
} catch (e) {
LogUtil.error("Error capturing image: $e");
}
}
// 打开相册
void onTapAlbum() async {
try {
// 检查相册权限
if (!(await checkPhotosPermissions())) {
return;
}
final AssetPickerConfig config = AssetPickerConfig(
textDelegate: assetPickerTextDelegateFromLocale(Get.locale),
maxAssets: 9,
requestType: RequestType.image,
);
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
Get.context!,
pickerConfig: config,
);
toolsVisible.toggle();
if (assets != null) {
for (var asset in assets) {
final String? filePath = await asset.file.then((file) => file?.path);
if (filePath != null) {
LogUtil.info("Image path: $filePath");
// 生成消息并展示本地图片(展示并保存)
var message = generatePictureMessage(curChatID.value, filePath);
var msgData = displayMessage(
indexController.selectedGroup.value.groupType, message);
sendUploadPicture(filePath, msgData);
}
}
}
} catch (e) {
LogUtil.error("Error onTapAlbum image: $e");
}
}
Future<bool> checkCameraPermissions() async {
bool? isRead = DataSp.getPermissionGuideStatus();
if (isRead == null || !isRead) {
await showPermissionsGuideDialog();
}
// 请求相机权限
var status = await Permission.camera.request();
if (!status.isGranted) {
bool? hasAlreadyDenied = DataSp.getCameraPermissionDeniedStatus();
// 如果是首次拒绝权限,不弹出权限不足提示
if (hasAlreadyDenied == null || !hasAlreadyDenied) {
// 标记第一次拒绝权限
DataSp.putCameraPermissionDeniedStatus(true);
return false;
}
// 后续拒绝时提示权限不足
await showPermissionDeniedDialog(
descData: tr('permissionCameraInsufficient'));
return false;
}
// 延时操作避免缓存问题
await Future.delayed(const Duration(milliseconds: 500));
return true;
}
Future<bool> checkPhotosPermissions() async {
bool? isRead = DataSp.getPermissionGuideStatus();
if (isRead == null || !isRead) {
await showPermissionsGuideDialog();
}
// 请求相册权限
PermissionState permissionState =
await PhotoManager.requestPermissionExtend();
if (permissionState != PermissionState.authorized &&
permissionState != PermissionState.limited) {
bool? hasAlreadyDenied = DataSp.getPhotosPermissionDeniedStatus();
// 如果是第一次拒绝权限,不弹出权限不足提示
if (hasAlreadyDenied == null || !hasAlreadyDenied) {
// 标记第一次拒绝权限
DataSp.putPhotosPermissionDeniedStatus(true);
return false;
}
// 后续检查时提示权限不足
await showPermissionDeniedDialog(
descData: tr('permissionPhotoInsufficient'));
return false;
}
// 延时操作避免缓存问题
await Future.delayed(const Duration(milliseconds: 500));
return true;
}
Future<void> showPermissionDeniedDialog({required String descData}) async {
await Get.dialog(
WillPopScope(
onWillPop: () async => false, // 禁止返回按钮关闭弹窗
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
child: Container(
padding: const EdgeInsets.all(20.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
color: Colors.white,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tr('permissionInsufficient'),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
descData,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: ElevatedButton(
onPressed: Get.back,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.grey[600],
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), // 按钮圆角
),
),
child: Text(
tr('cancel'),
style: TextStyle(
fontSize: 16,
fontFamily: 'Alibaba PuHuiTi 3.0',
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 15),
Expanded(
child: ElevatedButton(
onPressed: () {
Get.back(); // 关闭弹窗
PhotoManager.openSetting(); // 跳转到系统设置页面
},
style: ElevatedButton.styleFrom(
backgroundColor: Styles.c_YellowColor,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
tr('goToSettings'),
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontFamily: 'Alibaba PuHuiTi 3.0',
fontWeight: FontWeight.w700,
),
),
),
),
],
),
],
),
),
),
),
barrierDismissible: false, // 点击背景不允许关闭弹窗
);
}
// 显示按钮引导弹窗
Future<void> showPermissionsGuideDialog() async {
await Get.dialog(
WillPopScope(
onWillPop: () async => false, // 禁止返回按钮关闭弹窗
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: Container(
padding: const EdgeInsets.all(20.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
color: Colors.white,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 引导标题
Text(
tr('permissionsGuildTitle'),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: 20),
// 第一部分:相册图标和文字
Row(
children: [
Icon(
Icons.photo_library,
size: 40,
color: Colors.blueAccent,
),
SizedBox(width: 10),
Expanded(
child: Text(
tr('photosDesc'),
style: TextStyle(fontSize: 16, color: Colors.black54),
),
),
],
),
SizedBox(height: 20),
// 第二部分:相机图标和文字
Row(
children: [
Icon(
Icons.camera_alt,
size: 40,
color: Colors.blueAccent,
),
SizedBox(width: 10),
Expanded(
child: Text(
tr('cameraDesc'),
style: TextStyle(fontSize: 16, color: Colors.black54),
),
),
],
),
SizedBox(height: 30),
// 继续按钮
ElevatedButton(
onPressed: () {
Navigator.of(Get.context!).pop();
DataSp.putPermissionGuideStatus(true);
},
style: ElevatedButton.styleFrom(
backgroundColor: Styles.c_YellowColor,
foregroundColor: Colors.white,
minimumSize: Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
),
child: Text(
tr('continue'),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
);
}