Saving a SwiftUI view to Photos

Many shapes and patterns can be created using Path and Canvas in SwiftUI. It would be great to be able to save these as images in the Photo Library. This is possible using UIKit, by first converting the SwiftUI View to UIImage and then saving this image to Photos.



Access to Photos library

In order to save an image to the Photo Library, the app has to be granted access by the user. It is necessary to set a property NSPhotoLibraryAddUsageDescription in the App Targets info.plist. This is set in Project > Targets > Info and adding a new key "Privacy - Photo Library Additions Usage Description". The text that is entered here is presented to the user when the App first tries to save an image to the Photos Library. This is related to built-in privacy protections, so that any app may only access the user’s Photos library if authorised. more information available at Delivering an Enhanced Privacy Experience in Your Photos App.

If this property is not set, the App will crash when trying to access the Photos Library.

... ViewToPhotos[xxx] [access] This app has crashed because it attempted to access privacy-sensitive data without a usage description. The app's Info.plist must contain an NSPhotoLibraryAddUsageDescription key with a string value explaining to the user how the app uses this data.

Add access to users Photo Library to App properties
Add access to users Photo Library to App properties



Save an image to Photos

Add an image to the Assets for the App. An instance of UIImage is created in the SwiftUI view and UIImageWriteToSavedPhotosAlbum is used to add the image to the Photo Library on the device. The Alert will show the first time the App tries to add an image to the Photo Library, allowing the user to grant (or not) access to this feature. This sets a setting for the App that can be changed later in Settings App on the device.

 1struct SaveImageView: View {
 2    
 3    let image = UIImage(named: "cake")!
 4    
 5    var body: some View {
 6        VStack {
 7            Text("Sticky Toffee Cake")
 8            
 9            Image(uiImage: image)
10                .resizable()
11                .scaledToFill()
12                .frame(width: 250, height: 220, alignment: .center)
13                .clipped()
14
15            Spacer().frame(height:100)
16            
17            Button {
18                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
19            } label: {
20                HStack {
21                    Image(systemName: "photo.on.rectangle.angled")
22                    Text("Add to Photos")
23                }
24                .font(.title)
25                .foregroundColor(.purple)
26            }
27            Spacer()
28        }
29    }
30}

Alert is shown first time App tries to access Photo Library
Alert is shown first time App tries to access Photo Library



Alert to allow app to access Photo Library

Alert to allow app to access Photo Library



Convert SwiftUI view to Image

UIHostingController is commonly used to host SwiftUI views inside a UIKit view hierarchy. As such, it can be used to convert the SwiftUI view into a UIImage object. We start with a function convertViewToUiImage in the SwiftUI view that takes a specific view as a parameter and creates a UIHostingController with the view as the base. UIGraphicsImageRenderer is used to render a UIImage of the SwiftUI view. Note that this was initially moving the view down and leaving a blank space at the top of the image in the Photo Library, until the SwiftUI view was set to ignore the safe area using edgesIgnoringSafeArea.

 1struct TextView: View {
 2    var body: some View {
 3        Color.green.opacity(0.4)
 4            .cornerRadius(20)
 5            .frame(width: 250, height: 100, alignment: .center)
 6            .overlay(
 7                Text("Text to Image")
 8                    .font(.largeTitle)
 9            )
10            .edgesIgnoringSafeArea(.all)
11    }
12}
 1struct TextCardView: View {
 2    var textView = TextView()
 3    
 4    var body: some View {
 5        VStack {
 6            Text("Text outside of the textView")
 7            
 8            textView
 9            
10            Spacer().frame(height:100)
11            
12            Button {
13                let image = convertViewToUiImage(textView)
14                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
15            } label: {
16                HStack {
17                    Image(systemName: "photo.on.rectangle.angled")
18                    Text("Add to Photos")
19                }
20                .font(.title)
21                .foregroundColor(.purple)
22            }
23            
24            Spacer()
25        }
26    }
27    
28    func convertViewToUiImage(_ myView: TextView) -> UIImage {
29        var uiImage = UIImage(systemName: "exclamationmark.triangle.fill")!
30        let controller = UIHostingController(rootView: myView)
31       
32        if let view = controller.view {
33            let contentSize = view.intrinsicContentSize
34            view.bounds = CGRect(origin: .zero, size: contentSize)
35            view.backgroundColor = .lightGray
36
37            let renderer = UIGraphicsImageRenderer(size: contentSize)
38            uiImage = renderer.image { _ in
39                view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
40            }
41        }
42        return uiImage
43    }
44}

Add a simple SwiftUI view with text to Photo Library
Add a simple SwiftUI view with text to Photo Library



View needs to specify 'edgesIgnoringSafeArea' to avoid offset in saved Photo
View needs to specify 'edgesIgnoringSafeArea' to avoid offset in saved Photo



Add extension to view

It is not great having the function convertViewToUiImage in the middle of the SwiftUI view and it is limited to only working on the textView. It would be better to define this functionality as an extension to the View protocol so that it could be used on any view.

 1extension View {
 2    func asUiImage() -> UIImage {
 3        var uiImage = UIImage(systemName: "exclamationmark.triangle.fill")!
 4        let controller = UIHostingController(rootView: self)
 5       
 6        if let view = controller.view {
 7            let contentSize = view.intrinsicContentSize
 8            view.bounds = CGRect(origin: .zero, size: contentSize)
 9            view.backgroundColor = .clear
10
11            let renderer = UIGraphicsImageRenderer(size: contentSize)
12            uiImage = renderer.image { _ in
13                view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
14            }
15        }
16        return uiImage
17    }
18}
 1struct TextView: View {
 2    var body: some View {
 3        Color.blue.opacity(0.5)
 4            .cornerRadius(20)
 5            .frame(width: 250, height: 100, alignment: .center)
 6            .overlay(
 7                Text("Text View 1")
 8                    .font(.largeTitle)
 9            )
10            .edgesIgnoringSafeArea(.all)
11    }
12}
13
14struct FaceView: View {
15    var body: some View {
16        ZStack {
17            Ellipse()
18                .fill(.blue.opacity(0.9))
19                .frame(width: 200, height: 200, alignment: .center)
20            Ellipse()
21                .fill(.yellow)
22                .frame(width: 40, height: 30, alignment: .center)
23                .offset(x: -30, y: -30)
24            Ellipse()
25                .fill(.yellow)
26                .frame(width: 40, height: 30, alignment: .center)
27                .offset(x: 30, y: -30)
28            Path()
29            Ellipse()
30                .fill(.yellow)
31                .frame(width: 50, height: 20, alignment: .center)
32                .offset(x: 00, y: 50)
33        }
34        .frame(width: 200, height: 200, alignment: .center)
35        .edgesIgnoringSafeArea(.all)
36    }
37}
 1struct UsingExtensionView: View {
 2    var view1 = TextView()
 3    var view2 = FaceView()
 4
 5    var body: some View {
 6        VStack {
 7            Text("Text outside of the textView")
 8            
 9            view1
10            
11            Button {
12                let image = view1.asUiImage()
13                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
14            } label: {
15                HStack {
16                    Image(systemName: "photo.on.rectangle.angled")
17                    Text("Add to Photos")
18                }
19                .font(.title3)
20                .foregroundColor(.purple)
21            }
22
23            view2
24            
25            Button {
26                let image = view2.asUiImage()
27                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
28            } label: {
29                HStack {
30                    Image(systemName: "photo.on.rectangle.angled")
31                    Text("Add to Photos")
32                }
33                .font(.title3)
34                .foregroundColor(.purple)
35            }
36            
37            Spacer()
38        }
39    }
40}

Save SwiftUI views to Photo Library with View extension
Save SwiftUI views to Photo Library with View extension



Save SwiftUI views to Photo Library with View extension

Save SwiftUI views to Photo Library with View extension



Save changes to SwiftUI view to Photos

Let's use the Polygon Model and ViewModel as defined in Persist data with UserDefaults in SwiftUI. A property is added to the Polygon model to return the name of the shape based on the number of sides in the shape. A PolygonView is defined to display the polygon shape based on properties in the model. A SwiftUI view is defined that displays the PolygonView with a slider to change the number of sides and buttons to change the color and the solid or outline shape.

A button is added to save the PolygonView to Photos. The action of this button creates a new instance of PolygonView and converts it to a UIImage using the extension method asUiImage. It is now possible to save a regular polygon shape to photo library, then change the shape and save another image to Photo library.

 1struct PolygonView: View {
 2    @ObservedObject var polygonVm: PolygonViewModel
 3    
 4    var body: some View {
 5        VStack() {
 6            Text(polygonVm.shapeName)
 7                .font(.title)
 8            
 9            ZStack {
10                RegularPolygon(sides: Int(polygonVm.sides))
11                    .stroke(polygonVm.solid ? Color.clear : polygonVm.shapeColor, lineWidth: 6.0)
12                RegularPolygon(sides: Int(polygonVm.sides))
13                    .fill(polygonVm.solid ? polygonVm.shapeColor : Color.clear)
14            }
15            .frame(width: 200, height: 200)
16        }
17        .padding()
18    }
19}
 1struct UsingPolygonView: View {
 2    @ObservedObject var polygonVm: PolygonViewModel
 3    
 4    var body: some View {
 5        VStack(spacing:40) {
 6            PolygonView(polygonVm: polygonVm)
 7            
 8            HStack(spacing:50) {
 9                Spacer()
10                VStack {
11                    Text("Sides \(polygonVm.sides, specifier: "%.0F" )")
12                    Slider(value: $polygonVm.sides.animation(.spring()),
13                           in: 3...20,
14                           minimumValueLabel: Text("3"),
15                           maximumValueLabel: Text("20")) {
16                    }
17                }
18                Spacer()
19            }
20                        
21            HStack(spacing:20) {
22                Spacer()
23
24                Button(polygonVm.solid ? "Outline" : "Solid") {
25                    withAnimation(.easeInOut) {
26                        polygonVm.solid.toggle()
27                    }
28                }
29                .buttonStyle(BlueButtonStyle())
30
31                Button("Increment Color") {
32                    withAnimation(.default) {
33                        polygonVm.nextColor()
34                    }
35                }
36                .buttonStyle(BlueButtonStyle())
37
38                Spacer()
39            }
40            
41            Button {
42                let image = PolygonView(polygonVm: polygonVm)
43                    .edgesIgnoringSafeArea(.all)
44                    .asUiImage()
45                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
46            } label: {
47                HStack {
48                    Image(systemName: "photo.on.rectangle.angled")
49                    Text("Add to Photos")
50                }
51                .font(.title3)
52                .foregroundColor(.purple)
53            }
54            
55            Spacer()
56        }
57    }
58}

Save Polygon shape from SwiftUI view to Photo Library
Save Polygon shape from SwiftUI view to Photo Library



Save different polygon shapes from SwiftUI view to Photo Library

Save different polygon shapes from SwiftUI view to Photo Library





Conclusion

I feel that saving a SwiftUI view as an image in the Photo Library should be native to SwiftUI, but it is not there yet. So it can be done by first converting the SwiftUI view to a UIImage and then saving this to the Photo Library. The NSPhotoLibraryAddUsageDescription property has to be set in the info properties list for the app, with text explaining why the App needs access to Photos. Converting the SwiftUI view to a UIImage is done by defining an extension on the View protocol that uses UIKit to do the conversion. The extension method can then be applied to the view just before sending to the Photo Library.