使用 PhotosPicker 轻松从照片库添加图片

SwiftUI 的 PhotosPicker 组件使得从用户照片库中选择并添加图片变得简单而高效。本文将逐步指导您如何在 SwiftUI 应用中实现这一功能,提升用户体验。

使用 PhotosPicker 轻松从照片库添加图片

在开发过程中,您可能会遇到需要从用户的照片库中选择图片的情况。为此,SwiftUI 提供了一个非常方便的组件——PhotosPicker。

什么是 PhotosPicker

PhotosPicker 实在 iOS 16 和 iPadOS 16 中引入的一个新组件,旨在简化用户从照片库中选择图片的过程,开发者无需手动处理照片选择的逻辑。PhotosPicker 提供了一个简洁且功能强大的接口,能够轻松集成到 SwiftUI 应用中。

为什么使用 PhotosPicker

之前的版本中,开发者通常需要依赖 UIKit 的 UIImagePickerController 来实现类似功能,这个过程需要引入 UIKit 组件,并且开发者需要管理复杂的状态和界面更新逻辑,增加了混合框架编程的复杂性。

使用 PhotosPicker 的主要优势在于它与 SwiftUI 的深度集成,使用更少的代码实现照片选择功能,同时保持与系统原生外观一致。此外,PhotosPicker 还提供了更灵活的配置选项,允许开发者轻松指定允许的媒体类型,如仅选择图片或视频等。

使用 PhotosPicker 组件

基本用法

在使用 PhotosPicker 之前,首先需要在项目中导入 PhotosUI 框架,这个框架包含了所有与照片选择相关的功能,默认包含在 SwiftUI 框架中。

import PhotosUI

接下来,我们创建一个 PhotosPickerItem 类型的新变量来存储所选图像。

import SwiftUI
import PhotosUI

struct PhotosPickerView: View {

    @State private var avatarPhotoItem: PhotosPickerItem?

    var body: some View {
        VStack {
            PhotosPicker(
                "选择图片",
                selection: $avatarPhotoItem)
        }
    }
}

添加图片预览 / loadTransferable

PhotosPicker 不会自动处理显示,因此我们可以手动为它添加显示。从 PhotosPicker 中获取到的数据是 Data 类型,而 SwiftUI 的 Image 组件没有直接处理 Data 的构造函数,因此无法直接使用 Image() 组件来显示。

loadTransferable 是 photoKit 提供,可以用于从PhotosPickerItem 中加载和转换用户选择的媒体数据。非常适合处理可能需要从系统文件或资源中异步加载数据的场景,比如从照片库中提取图片。

import PhotosUI
import SwiftUI

struct PhotosPickerView: View {

    @State private var avatarPhotoItem: PhotosPickerItem?
    @State private var selectedImage: Image?

    var body: some View {
        VStack(spacing: 16) {

            // 显示选中的图片预览
            if let selectedImage = selectedImage {
                selectedImage
                    .resizable()
                    .scaledToFill()
                    .frame(width: 100, height: 100)
                    .clipShape(Circle())
                    .overlay(Circle().stroke(Color.white, lineWidth: 4))
            }

            // 当用户未选择头像时显示“选择头像”,选择后显示“更换头像”
            let buttonText = selectedImage == nil ? "选择头像" : "更换头像"

            PhotosPicker(
                buttonText, selection: $avatarPhotoItem, matching: .images)
        }
        .padding()
        .task(id: avatarPhotoItem) {
            // 使用新的方式加载和转换图片
            if let avatarPhotoItem = avatarPhotoItem {
                if let data = try? await avatarPhotoItem.loadTransferable(
                    type: Data.self),
                    let uiImage = UIImage(data: data)
                {
                    selectedImage = Image(uiImage: uiImage)
                }
            }
        }
    }
}

struct PhotosPicker_Previews: PreviewProvider {
    static var previews: some View {
        PhotosPickerView()
    }
}

它是异步的,使用 Swift 的 async/await 语法,确保在加载较大文件时不会阻塞主线程,提升应用的响应速度和用户体验。

过滤媒体类型 / matching

PhotosPicker 提供了可选的matching 参数,用于过滤用户可以从照片库中选择的媒体类型。

下面这个代码,限制用户只能选择视频类型:

import PhotosUI
import SwiftUI

struct PhotosPickerView: View {

    @State private var avatarPhotoItem: PhotosPickerItem?

    var body: some View {
        VStack {
            PhotosPicker(
                "选择视频",
                selection: $avatarPhotoItem,
                matching: .videos)
        }
    }
}

struct PhotosPicker_Previews: PreviewProvider {
    static var previews: some View {
        PhotosPickerView()
    }
}

matching 提供了多个预设的类型供选择,并且支持灵活组合使用:

.any(of: [PHPickerFilter])允许用户选择多种类型的媒体。比如,允许选择图片或视频:

matching: .any(of: [.images, .videos])

.not(PHPickerFilter):排除特定类型的媒体。比如,不允许选择图片:

matching: .not(.images)

.livePhotos:仅允许选择实况照片。

matching: .livePhotos

.videos:仅允许选择视频。

matching: .videos

.images:仅允许选择图片。

matching: .images

使用 .photosPicker 组件修饰器

PhotosPicker 组件在 SwiftUI 中非常实用,能够方便用户从照片库中选择图片。然而,它是通过 UI 触发的,也就是说,必须由用户点击一个 Button 或其他触发器来打开选择器。

然而,在某些场景下,你可能希望在符合特定条件时,自动触发 PhotosPicker,而无需用户手动点击。例如,当用户首次打开应用时,如果他们还没有设置头像,你可能希望自动打开照片选择器,提示他们选择一张照片。

在这种情况下,你可以使用 .photosPicker 修饰器,并结合一个绑定到 Bool 类型的状态变量来控制选择器的显示。通过这种方式,选择器可以在编程条件满足时自动弹出,而不是依赖用户的手动操作。

官方文档中的 .photosPicker 用法如下:

.photosPicker(isPresented: $shouldShowPicker, selection: $selectedItems, matching: .images)

此外,你可以使用 .onChange 修饰器来监控 selectedItems 的变化,并在用户选择图片后,自动执行一些操作,比如加载和显示选中的图片。使用示例如下:

import SwiftUI
import PhotosUI

struct ContentView: View {
    @State private var shouldShowPicker = false
    @State private var selectedItems: [PhotosPickerItem] = []
    @State private var selectedImage: UIImage?
    
    var body: some View {
        VStack {
            if let image = selectedImage {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 200, height: 200)
                    .clipShape(Circle())
                    .overlay(Circle().stroke(Color.white, lineWidth: 4))
                    .shadow(radius: 10)
            } else {
                Text("没有选择图片")
                    .foregroundColor(.gray)
            }
            
            Button("选择图片") {
                shouldShowPicker = true
            }
        }
        .photosPicker(isPresented: $shouldShowPicker, selection: $selectedItems, matching: .images)
        .onChange(of: selectedItems) { newItems in
            Task {
                if let firstItem = newItems.first, 
                   let data = try? await firstItem.loadTransferable(type: Data.self),
                   let uiImage = UIImage(data: data) {
                    selectedImage = uiImage
                }
            }
        }
        .onAppear {
            // 示例场景:当视图首次出现时,自动打开选择器
            if selectedImage == nil {
                shouldShowPicker = true
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

自定义 PhotoPicker 外观(高级)

使用 .photosPickerStyle 修改样式

Embed the Photos Picker in your app - WWDC23 - Videos - Apple Developer
Discover how you can simply, safely, and securely access the Photos Library in your app. Learn how to get started with the embedded…

在 iOS17 或以上,PhotosPicker 组件支持三种样式(不支持 .photosPicker 修饰器):

  • presentation(默认)
  • inline(类似手记 App 中的效果)
  • compact(比手记 App 中更简单,适合空间非常受限的场景)

使用 .frame 限制高度

还可以使用 .padding 添加边距。

使用 .photosPickerDisabledCapabilities 隐藏选择器指定元素

  • 隐藏取消和确认按钮。
    • 推荐结合将 PhotoPicker 的 selectionBehavior 参数设置为 .continuous
      • 如果使用 .continuous,则取消和确认按钮均会被隐藏
      • 如果没有使用.continuous,则只会隐藏取消按钮。添加按钮仍然可见。
  • 隐藏搜索功能
  • 隐藏图标/相册选择器
  • 隐藏底部工具栏功能——隐藏后只显示 X Photos Selected

使用 .photosPickerAccessoryVisibiity 隐藏所有元素

默认隐藏所有:

.photosPickerAccessoryVisibility(.hidden)

你还可以只隐藏某一个方向,或某些方向:

.photosPickerAccessoryVisibility(.hidden, edges: .bottom)

.photosPickerAccessoryVisibility(.hidden, edges: [.bottom,.leading])

示例:创建手记 App 风格的照片选择器

  • 使用  .photosPickerAccessoryVisibility(.hidden) 隐藏所有功能按钮。
不要使用 .photosPickerDisabledCapabilities(),标题和底部状态栏无法隐藏。

完整代码如下:

import PhotosUI
import SwiftUI

struct PhotoPickerView: View {
    @Environment(\.dismiss) private var dismiss
    @State private var selectedItems: [PhotosPickerItem] = []
    @State private var selectedImage: UIImage?
    @State private var showingMockup = false

    var body: some View {
        NavigationStack {
            VStack {
                Text("Image Picker")
                    .fontDesign(.monospaced)
                Spacer()
                PhotosPicker(
                    selection: $selectedItems,
                    selectionBehavior: .continuous,
                    matching: .images,
                    photoLibrary: .shared()
                ) {
                    Text("选择照片")
                }
                .photosPickerStyle(.inline)
                .ignoresSafeArea()
                .photosPickerDisabledCapabilities(.selectionActions)
                .photosPickerAccessoryVisibility(
                    .hidden, edges: .all
                )
                .frame(height: 200)

            }
            .onChange(of: selectedItems) {
                // 处理选择的图片
                Task {
                    if let item = selectedItems.first {
                        if let data = try? await item.loadTransferable(
                            type: Data.self),
                            let image = UIImage(data: data)
                        {
                            selectedImage = image
                            showingMockup = true
                        }
                    }
                }
            }
            .navigationDestination(isPresented: $showingMockup) {
                if let image = selectedImage {
                    PhoneMockupView(image: image)
                }
            }
        }
    }
}

#Preview {
    PhotoPickerView()
}

还可以把它放到一个 sheet 模态窗口中:

Text("")
  .sheet(isPresented: .constant(true)) {
      PhotoPickerView()
          .presentationDetents([.height(200), .medium])
          .presentationBackgroundInteraction(.enabled)
          .presentationDragIndicator(.hidden)
  }

示例:创建 Picsew 风格的照片选择器

下面这个代码重点实现:

  • App 启动即显示全屏照片选择器界面,而不用通过 .sheet 窗口拉起。——这是通过设置为 .inline 模式实现。
  • 隐藏照片选择器自带的导航栏和工具栏。—— 通过设置 photosPickerAccessoryVisibility 实现。
  • 点击任意一张图片,自动执行处理,无需点击确认按钮。—— 这是通过设置 PhotosPicker 的 selectionBehavior 参数为 .continuous 实现。
import PhotosUI
import SwiftUI

struct PhotoPickerView: View {
    @Environment(\.dismiss) private var dismiss
    @State private var selectedItems: [PhotosPickerItem] = []
    @State private var selectedImage: UIImage?
    @State private var showingMockup = false

    var body: some View {
        NavigationStack {
            VStack {
                PhotosPicker(
                    selection: $selectedItems,
                    selectionBehavior: .continuous,
                    matching: .images,
                    photoLibrary: .shared()
                ) {
                    Text("选择照片")
                }
                .photosPickerStyle(.inline)
                .photosPickerAccessoryVisibility(
                    .hidden, edges: .all
                )
                .ignoresSafeArea(.all)
            }
            .onChange(of: selectedItems) {
                // 处理选择的图片
                Task {
                    if let item = selectedItems.first {
                        if let data = try? await item.loadTransferable(
                            type: Data.self),
                            let image = UIImage(data: data)
                        {
                            selectedImage = image
                            showingMockup = true
                        }
                    }
                }
            }
            .navigationDestination(isPresented: $showingMockup) {
                if let image = selectedImage {
                    PhoneMockupView(image: image)
                }
            }
        }
    }
}

#Preview {
    PhotoPickerView()
}
0:00
/0:06

在 Menu 中使用 PhotosPicker

在 Menu 中直接使用 PhotosPicker,会导致无法拉起图片选择器,这是一个已知的问题。使用 .photosPicker 修饰器而不是 PhotosPicker 可以解决这个问题

参考:

PhotosPicker doesn’t appear when d… | Apple Developer Forums

在 macOS 上使用 PhotosPicker

photosPicker 原生支持 macOS,但打开的是照片应用,而不是访达应用——这可能不太符合 macOS 使用习惯。

  • 不幸的是,目前 photosPicker 并不支持通过配置在 macOS 上自动拉起访达。只能使用 .fileImporter 组件来导入访达文件。
  • 幸运的是,.fileImporter 是 SwiftUI 组件而不是 AppKit,因此用法非常简单——和.sheet类似。
fileImporter(isPresented:allowedContentTypes:onCompletion:) | Apple Developer Documentation
Presents a system interface for allowing the user to import an existing file.
struct PickTemplatesDirectoryButton: View {
     @State private var showFileImporter = false
     var onTemplatesDirectoryPicked: (URL) -> Void


     var body: some View {
         Button {
             showFileImporter = true
         } label: {
             Label("Choose templates directory", systemImage: "folder.circle")
         }
         .fileImporter(
             isPresented: $showFileImporter,
             allowedContentTypes: [.directory]
         ) { result in
              switch result {
              case .success(let directory):
                  // gain access to the directory
                  let gotAccess = directory.startAccessingSecurityScopedResource()
                  if !gotAccess { return }
                  // access the directory URL
                  // (read templates in the directory, make a bookmark, etc.)
                  onTemplatesDirectoryPicked(directory)
                  // release access
                  directory.stopAccessingSecurityScopedResource()
              case .failure(let error):
                  // handle error
                  print(error)
              }
         }
     }
 }

如何让 App 支持拖拽文件导入

更多开发文章

SwiftData 教程与实例:简化 iOS 开发数据持久化(一)
SwiftData 是 Apple 为 iOS 开发者提供的新型类 ORM 工具,简化数据库管理,无需编写 SQL,本文包含 SwiftData 教程和使用实例。
使用 Figma 进行 visionOS App 原型设计(一)
了解原型设计工具 Figma 的基础用法,以及如何使用它来进行 visionOS 应用的原型设计。