Skip to content
vrhermit

vrhermit

Code & Writing by Joseph Simpson

  • Canvatorium
  • Technical
  • Professional
  • Personal
vrhermit
vrhermit
Code & Writing by Joseph Simpson
Retrospective | Technical

Generating Shareable Text Images

ByJoseph December 20, 2019June 25, 2023
SwiftUI

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.

You can download a sample project here.Download

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.

Share this:

  • Click to share on X (Opens in new window) X
  • Click to share on Tumblr (Opens in new window) Tumblr
  • More
  • Click to print (Opens in new window) Print
  • Click to share on LinkedIn (Opens in new window) LinkedIn
  • Click to share on Reddit (Opens in new window) Reddit
  • Click to share on Pinterest (Opens in new window) Pinterest
  • Click to share on Telegram (Opens in new window) Telegram
  • Click to share on WhatsApp (Opens in new window) WhatsApp

Like this:

Like Loading...
Post Tags: #SwiftUI
Next: Time Tracking in 2020
Previous: My Device Log

Get my articles in your email

Join 22 other subscribers

A-Frame AI AppUpdate AR BabylonJS Books Career ChatGPT CloudKit CoreData FileMaker Food Gaming Kadence MixedReality parody PlayCanvas Podcast SpatialComputing SwiftUI Thoughts visionOS VisionPro VR VueJS WebXR WordPress

Work with Me

Ready to streamline your workflows and enhance your digital presence?

Do you want to take your first step into Spatial Computing?

Discover how Radical Application Development can help transform your business.

Get in touch or learn more at radicalappdev.com

Mastodon Mastodon
Privacy & Cookies: This site uses cookies. By continuing to use this website, you agree to their use.
To find out more, including how to control cookies, see here: Privacy Policy

Follow my work

Github Linkedin YouTube

© 2025 Joseph Simpson | Radical Application Development

  • Canvatorium
  • Technical
  • Professional
  • Personal
 

Loading Comments...
 

    Search
    %d