iosswiftphotokit

How to save/replace image on iOS using swift and PhotoKit


This seems like super easy job, but I struggle to replace image after its edit.

First dummy UI to pick image from gallery:

import SwiftUI
import Photos
import PhotosUI

struct ContentView: View {
    @State private var selectedImage: UIImage? = nil
    @State private var isPickerPresented: Bool = false

    var body: some View {
        VStack {
            if let image = selectedImage {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(height: 300)
            } else {
                Text("No image selected")
            }

            Button(action: {
                isPickerPresented = true
            }) {
                Text("Pick a Photo")
            }

            Button(action: {
                selectedImage = editPhoto(selectedImage!)
            }) {
                Text("Apply Filter")
            }

            Button(action: {
                savePhoto(selectedImage!)
            }) {
                Text("Save Photo")
            }

            Button(action: {
                fetchPhotos().first.map { asset in
                    replacePhoto(asset: asset, with: selectedImage!)
                }
            }) {
                Text("Replace Photo")
            }
        }
        .onAppear {
            requestPhotoLibraryAccess()
        }
        .sheet(isPresented: $isPickerPresented) {
            PhotoPicker(selectedImage: $selectedImage)
        }
    }
}

struct PhotoPicker: UIViewControllerRepresentable {
    @Binding var selectedImage: UIImage?

    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        var parent: PhotoPicker

        init(parent: PhotoPicker) {
            self.parent = parent
        }

        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            picker.dismiss(animated: true)
            guard let result = results.first else { return }

            if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
                result.itemProvider.loadObject(ofClass: UIImage.self) { image, error in
                    DispatchQueue.main.async {
                        if let image = image as? UIImage {
                            self.parent.selectedImage = image
                        }
                    }
                }
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeUIViewController(context: Context) -> PHPickerViewController {
        var configuration = PHPickerConfiguration()
        configuration.selectionLimit = 1
        configuration.filter = .images

        let picker = PHPickerViewController(configuration: configuration)
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
        // No updates required
    }
}

getting permission:

func requestPhotoLibraryAccess() {
    PHPhotoLibrary.requestAuthorization { status in
        switch status {
        case .authorized:
            print("Access granted")
        case .limited:
            print("Limited access granted")
        case .denied, .restricted:
            print("Access denied")
        case .notDetermined:
            print("Not determined yet")
        @unknown default:
            fatalError("Unhandled authorization status")
        }
    }
}

while having this in info.plist

<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to your photos to allow you to edit them.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need access to save the edited photos back to your library.</string>

Still not so important ways how to get image/asset (for replace), edit photo

func fetchPhotos() -> [PHAsset] {
    var assets: [PHAsset] = []
    let fetchOptions = PHFetchOptions()
    fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]

    let fetchResult = PHAsset.fetchAssets(with: .image, options: fetchOptions)
    fetchResult.enumerateObjects { asset, _, _ in
        assets.append(asset)
    }
    return assets
}

func editPhoto(_ image: UIImage) -> UIImage? {
    return applyFilterWithFixedOrientation(
        image: image,
        filterName: "CIGaussianBlur",
        parameters: [kCIInputRadiusKey: 20.0]
    )
}

func applyFilterWithFixedOrientation(image: UIImage, filterName: String, parameters: [String: Any] = [:]) -> UIImage? {
    guard let ciImage = CIImage(image: image) else { return nil }

    // Create the filter
    let filter = CIFilter(name: filterName)
    filter?.setValue(ciImage, forKey: kCIInputImageKey)

    // Set additional parameters if provided
    for (key, value) in parameters {
        filter?.setValue(value, forKey: key)
    }

    // Get the output image
    if let outputImage = filter?.outputImage {
        let context = CIContext()
        if let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
            // Fix the orientation
            return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
        }
    }
    return nil
}

func savePhoto(_ image: UIImage) {
    PHPhotoLibrary.shared().performChanges({
        PHAssetChangeRequest.creationRequestForAsset(from: image)
    }) { success, error in
        DispatchQueue.main.async {
            if success {
                print("Photo saved successfully")
            } else {
                print("Failed to save photo: \(error?.localizedDescription ?? "unknown error")")
            }
        }
    }
}

This is probably the not working part:

func replacePhoto(asset: PHAsset, with image: UIImage) {
    PHPhotoLibrary.shared().performChanges({
        guard let contentEditingInput = getEditingInput(for: asset) else {
            print("Failed to retrieve editing input.")
            return
        }

        // Prepare editing output
        let editingOutput = PHContentEditingOutput(contentEditingInput: contentEditingInput)

        // Save new image data to the editing output's URL
        if let jpegData = image.jpegData(compressionQuality: 1.0) {
            try? jpegData.write(to: editingOutput.renderedContentURL, options: .atomic)
        }

        // Update the asset with the new editing output
        let request = PHAssetChangeRequest(for: asset)
        request.contentEditingOutput = editingOutput
    }) { success, error in
        DispatchQueue.main.async {
            if success {
                print("Image replaced successfully.")
            } else {
                print("Failed to replace image: \(error?.localizedDescription ?? "Unknown error")")
            }
        }
    }
}

func getEditingInput(for asset: PHAsset) -> PHContentEditingInput? {
    let semaphore = DispatchSemaphore(value: 0)
    var editingInput: PHContentEditingInput?

    asset.requestContentEditingInput(with: nil) { input, _ in
        editingInput = input
        semaphore.signal()
    }

    semaphore.wait()
    return editingInput
}

Because when I try to replace the photo I've picked, I got this error:

Failed to replace image: The operation couldn’t be completed. (PHPhotosErrorDomain error 3303.)

What am I missing or why I cannot save it to original place?


Solution

  • I finally figured out the issue:

    Final simplified (for posting) code that works for me looks like this:

    if let firstAsset = fetchPhotos().first {
                firstAsset.requestContentEditingInput(with: nil) { input, _ in
                    if let input {
                        if let url = input.fullSizeImageURL {
                            let orientation = input.fullSizeImageOrientation
                            if let inputImage = CIImage(contentsOf: url, options: nil)?.oriented(forExifOrientation: orientation) {
                                // with gray filter
                                if let editedPhoto = inputImage.applyFilterWithFixedOrientation(filterName: "CIPhotoEffectMono") {
                                    let editingOutput = PHContentEditingOutput(contentEditingInput: input)
                                    let adjustmentData = PHAdjustmentData(formatIdentifier: "", formatVersion: "1.0", data: "📸".data(using: .utf8)!)
    
                                    editingOutput.adjustmentData = adjustmentData // this is not optional, but it is not required to be set, otherwise 3303 error will be thrown
    
                                    do {
    //                                        try editedPhoto.heicData()?.write(to: editingOutput.renderedContentURL, options: .atomic)
                                        if let jpegData = editedPhoto.jpegData(compressionQuality: 1.0) { // JPEG is mandatory otherwise 3302 error will be thrown
                                            try jpegData.write(to: editingOutput.renderedContentURL, options: .atomic)
                                        }
                                    } catch {
                                        print("Failed to write edited image: \(error.localizedDescription)")
                                    }
                                    if firstAsset.canPerform(.content) {
                                        print("Can perform content editing")
                                    }
                                    PHPhotoLibrary.shared().performChanges({
                                        let request = PHAssetChangeRequest(for: firstAsset)
                                        request.contentEditingOutput = editingOutput
                                    }, completionHandler: {success, error in
                                        print("Success: \(success), Error: \(error?.localizedDescription ?? (success ? "" : "unknown error"))")
                                    })
                                }
                            }
                        }
                    }
                }
            }