Generating Shareable Text Images

One of the core features of Retrospective Timelines is the ability to share an Event as an image. I’m working in SwiftUI on this project so my first attempt was to see if I could render SwiftUI views as images. Last June Erica Sadun wrote a post about rendering SwiftUI on macOS Mojave that gave me some ideas to try. There is some sample code in that post that renders a UIView as an image, but the view is hosting SwiftUI views in the first place. I tried to adapt this to my needs in the app, but I quickly ran into issues.

First, I couldn’t find a way to make SwiftUI/iOS render view content that was not on screen. If I wanted to make a large resolution image I would need to scale it to fit on screen on an iOS device in order to save it as an image. Second, scaling the image in this manner introduced all sorts of scaling artifacts. The backgrounds look OK but the text was awful.

The next I thing I tried ended up being my “good enough” solution. I rendered a UIView offscreen with all of the text views manually positioned and added to this view.

Content View contains a simple UI. At the top is an Image view that is showing a live preview of the final image that we are rendering and sharing. There is a text field to enter a string of text and a simple color picker object in a scroll view. The function called `createViewToRender()` is doing most of the work here. I create some views, position them, and add them to a parent object.

struct ContentView: View {
    
    @Environment(\.viewController) private var viewControllerHolder: UIViewController?
    @State var selectedColor: String = "user_purple"
    @State var displayText: String = ""
    
    var body: some View {
        VStack(alignment: .center) {
            
            Image(uiImage: self.renderViewAsImage)
                .resizable()
                .mask(RoundedRectangle(cornerRadius: 10))
                .overlay(RoundedRectangle(cornerRadius: 10)
                    .stroke(Color.gray, lineWidth: 0.5)
            )
                .scaledToFit()
                .shadow(color: Color.gray, radius: 1, x: 0, y: 0)
                .padding()
            
            TextField("Enter a string", text: self.$displayText)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            
            ScrollView(.horizontal) {
                ColorPicker(selectedColor: self.$selectedColor)
            }
            
            Spacer()
            sharingActionButton
                .padding()
            Spacer()
            
        }
        .frame(minWidth: nil, idealWidth: nil, maxWidth: 800, minHeight: nil, idealHeight: nil, maxHeight: nil, alignment: .center)
        .navigationBarTitle(Text("Share"))
    }
    
    var sharingActionButton: some View {
        Button(action: {
            self.shareEvent()
        }, label: {
            VStack {
                Image(systemName: "square.and.arrow.up")
                    .font(Font.body.weight(.bold))
                    .padding()
                Text("Share as image and/or text")
                    .font(.footnote)
            }
        })
    }
    
    private func roundedFont(fontSize: CGFloat, weight: UIFont.Weight) -> UIFont {
        if let descriptor = UIFont.systemFont(ofSize: fontSize, weight: weight).fontDescriptor.withDesign(.rounded) {
            return UIFont(descriptor: descriptor, size: fontSize)
        } else {
            return UIFont.systemFont(ofSize: fontSize, weight: .regular)
        }
    }
    
    private var renderViewAsImage: UIImage {
        createViewToRender().renderAsImage()
    }
    
    private func createViewToRender() -> UIView {
        
        let labelPositionX: CGFloat = 40
        let labelWidth: CGFloat = 944
        
        let eventNamePositionY: CGFloat = 140
        let eventHeight: CGFloat = 300
        let eventFontSize: CGFloat = 60
        
        let background = UIView()
        background.backgroundColor = UIColor(named: self.selectedColor)!
        background.frame = CGRect(x: 0, y: 0, width: 1024, height: 576)
        
        
        let text = UILabel()
        text.frame = CGRect(x: labelPositionX, y: eventNamePositionY, width: labelWidth, height: eventHeight)
        text.text = self.displayText
        text.numberOfLines = 3
        text.textColor = UIColor.white
        text.font = roundedFont(fontSize: eventFontSize, weight: .bold)
        text.textAlignment = .center
        
        background.addSubview(text)

        return background
        
    }
    
    private func shareEvent() {
        let postText: String = self.displayText
        let postImage: UIImage = self.renderViewAsImage
        let activityItems = [postText, postImage] as [Any]
        
        let activityController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
        activityController.excludedActivityTypes = [.postToTencentWeibo, .postToVimeo, .assignToContact, .markupAsPDF, .openInIBooks, .postToWeibo]
        
        if (UIDevice.current.userInterfaceIdiom == .pad) {
            
            activityController.popoverPresentationController?.sourceView = viewControllerHolder?.view
            activityController.popoverPresentationController?.sourceRect = CGRect(x: (viewControllerHolder?.view?.bounds)!.maxX, y: (viewControllerHolder?.view?.bounds)!.minY, width: 0, height: 0)
            activityController.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.init(rawValue: 0) //.Down
            viewControllerHolder?.present(activityController, animated: true, completion: nil)
            
        } else {
            
            viewControllerHolder?.present(activityController, animated: true, completion: nil)
        }
        
    }
}

There are a couple utility code snippets that make help out the content view.

This help me access the hosting view controller so I can present the action sheet. I could have wrapped action sheet in SwiftUI but this was way faster to get up and running. I saw this somewhere on Stack Overflow, but I can’t remember where.

struct ViewControllerHolder {
    weak var value: UIViewController?
}

struct ViewControllerKey: EnvironmentKey {
    static var defaultValue: ViewControllerHolder { return ViewControllerHolder(value: UIApplication.shared.windows.first?.rootViewController ) }
}

extension EnvironmentValues {
    var viewController: UIViewController? {
        get { return self[ViewControllerKey.self].value }
        set { self[ViewControllerKey.self].value = newValue }
    }
}

This is what allows me to render the UIView as an image.

extension UIView {

    func renderAsImage() -> UIImage {
        let imageRenderer = UIGraphicsImageRenderer(bounds: bounds)
        if let format = imageRenderer.format as? UIGraphicsImageRendererFormat {
            format.opaque = true
        }
        
        
        let image = imageRenderer.image { context in
            context.cgContext.setAllowsFontSmoothing(true)
            context.cgContext.setShouldSmoothFonts(true)
            context.cgContext.setAllowsAntialiasing(true)
            context.cgContext.setShouldAntialias(true)
            context.cgContext.setAllowsFontSubpixelQuantization(true)
            context.cgContext.setShouldSubpixelQuantizeFonts(true)
            return layer.render(in: context.cgContext)
        }
        return image
    }
    

}

The color view as a flattened version of the one I’m using in the Timeline picker. This control just using a @Binding string variable where the string is the name of a color asset.

struct Colors : Identifiable {
    var id = UUID()
    var colorName: String = "user_purple"
}

extension Colors {
    static func all() -> [Colors] {
        
        // These strings reference named colors in the asset catelog
        return [
            Colors(colorName: "user_purple"),
            Colors(colorName: "user_light_blue"),
            Colors(colorName: "user_cyan"),
            Colors(colorName: "user_green"),
            Colors(colorName: "user_blue"),
            
            Colors(colorName: "user_red"),
            Colors(colorName: "user_orange"),
            Colors(colorName: "user_yellow"),
            Colors(colorName: "user_brown"),
            Colors(colorName: "user_grey"),
        ]
    }
}

struct ColorPicker: View {
    
    @Binding var selectedColor : String
    
    let colors = Colors.all()
    
    var body: some View {
            
        return
            
            VStack(alignment: .leading) {
                    
                HStack(alignment: .top) {
                    
                    ForEach(self.colors) { radColor in
                        
                        ZStack {
                            
                            // Add a selection circle to the current color
                            
                            if (radColor.colorName == self.selectedColor) {
                                Circle()
                                    .stroke(Color.gray, lineWidth: 3)
                                    .frame(width: 40, height: 40, alignment: .center)
                            }
                            
                            Button(action: {self.selectedColor = radColor.colorName}) {
                                Circle()
                                    .foregroundColor(Color(radColor.colorName))
                                    .frame(width: 32, height: 32, alignment: .center)
                                    .padding(4)
                            }
                            
                        } // END ZStack
                        
                    } // END ForEach
                    
                } // END HStack
                    
            } // END VStack
                
        .padding()
             
    }
}

I’m sure there are better ways to handle a feature like this, but this approach is fine for now. There are a number of changes I’d like to make in future versions.

  • Dynamic text / auto-layout. Currently the text views are manually positioned and sized around a simple layout, but this does not allow for different fonts and sizes.
  • It might be interesting to offer some gradients in addition to the colors
  • I’d like to add some decorations such as patterns or simple images that can make the images more interesting.