iCalvin

A Week With SwiftUI

Wow.

I can’t believe how packed this week has been. I was all prepared to get lots of new stuff to play around with, from Catalyst (née Marzipan) to MultiWindow support on iPad. And then some of the other stuff that I wasn’t anticipating like Sign In w/ Apple and some renewed opportunity of Today widgets were also catching my interest. But what really blew me away Monday was SwiftUI.

When Swift came out in 2014 I played around with it for a few hours but moved on to a more familiar language for iOS 8 work. Because of that I never really immersed myself in Swift until late 2016 and was playing catch up. That memory came up immediately while watching the SwiftUI announcement on Monday. Especially with some looming architectural changes coming up, and the potential for a SwiftUI development flow on iPadOS, this really seemed like where I wanted to focus my learning this week. And focus I did 😂

Once I finished the SwiftUI Tutorial provided by Apple I broke open my blog posting app project again. I wanted to migrate that to macOS this week, and SwiftUI seemed like as good a way to do that as any. This was the first of many times I wished I had read the release notes before getting started. They clearly state what I soon discovered, that Catalyst apps running on macOS using Swift UI aren’t rendering, I just saw an empty black window 🤷‍♂️. Easy enough to stick to iPad for now, and once that’s fixed run it on Mac and start playing around with windowing APIs.

Focusing pretty much exclusively on SwiftUI this week I was able to get a working port of my blog management app, and I’m actually pretty damn proud of it.

Screenshot of app in light and dark mode

So let’s look at some code! 👀


struct HomeView : View {
    @Binding var state: BlogEditingState
    var body: some View {
        return NavigationView {
            ComposeView(state: self.$state)
                .padding(.bottom)
                .navigationBarItems(
                    leading: ArchiveButton(archive: self.$state.archive),
                    trailing: SendButton(post: self.$state.writePost))
                .navigationBarTitle(
                    Text("@iCalvin")
                    displayMode: .large)
        }
    }
}

👆 This is the entire view for the main screen of the app. Obviously there’s more going around nested in that innocent looking ComposeView, but you can’t say this new framework doesn’t clean things up a bit. One struct with a body variable and some state, rather than a UIViewController with a handful of lifecycle functions.

The framework helped me think about how I wanted to organize stuff, but I did end up finding some blocking issues, which I wanna talk about in some more detail here. With a few design tweaks here and there though I ended up with something that worked with the current state of SwiftUI

First, I wanted to say that the initial documentation is pretty disappointing. Pretty much anything that wasn’t done in the tutorial had no documentation at all, which was really frustrating.

Screenshot of Archive


struct PostListView : View {
    var posts: [Post]
    
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack() {
                    ForEach(self.posts) { post in
                        PostSummaryView(post: post)
                    }
                    }.padding(.horizontal)
                    .frame(width: geometry.size.width)
                }.frame(width: geometry.size.width)
        }
    }
}

The first issue I had came from multi-line text. I wanted a scrolling list of my post archive, but whenever I added a Text component to a ScrollView it wouldn’t show me more than one line. I experimented around a bit with the .lineLimit(_ limit: Length?) function but it didn’t seem to be having any effect. One thing that confused me was that the behavior of setting the limit to 0 was different from a UILabel, where 0 value means as many as needed. In SwiftUI, you get this behavior by passing nil. Eventually (after filing a Radar) I noticed that this was called out in the release notes, so again I could have saved myself some time there. For now I’m just hardcoding a height onto the PostSummaryView depending on which type it is.


struct PostSummaryView : View {
    var post: Post
    var body: some View {
        HStack {
            if !(self.post.title?.isEmpty ?? true) {
                Text(post.title ?? "")
                    .color(UIColor(named: "text-prominent")?.swiftColor)
                    .lineLimit(nil)
                    .multilineTextAlignment(.leading)
                    .font(.headline)
                    .frame(height: 60)
            } else {
                Text(post.body ?? "")
                    .color(UIColor.label.swiftColor)
                    .lineLimit(nil)
                    .multilineTextAlignment(.leading)
                    .font(.body)
                    .frame(height: 120)
                }
            Spacer()
            }
            .padding()
            .background(UIColor.secondarySystemBackground.swiftColor)
            .cornerRadius(10)
    }
}

Another good call out is the GeometryReader component. It allows you to access the explicit size of the containing component, which helped a lot for configuring the scroll view with the correct width 👍. Even though it feels like a little bit of a hack, this is a much easier way of dealing with scroll views than on UIKit 💯

Screenshot of Tag View


struct TagViewer: View {
    @Binding var tags: [String]
    
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                HStack {
                    ForEach(self.tags.identified(by: \.self)) { tag in
                        return Text(tag)
                            .padding()
                            .background(UIColor.secondarySystemBackground.swiftColor)
                            .cornerRadius(44)
                            //.animation(.basic())
                            .transition(.move(edge: .leading))
                    }
                    }
                    .frame(height: geometry.size.height)
            }
        }
    }
}

The Tag editor was a bit tricky as well. I wanted the tag pills to have a nice bouncy feel about them, and for the new tag to animate in on commit. The animation itself was easy enough using the build in transitions, but I ended up finding another issue that seemed to result from the same autosizing bug in ScrollViews.

Screenshot of text bug

The animation itself works great, but the text rendering in the pill goes a bit crazy. For now I’ve just disabled the animation until a future build where hopefully this gets fixed.


var body: some View {
        func applyState() {
            self.tags.insert(self.draftTag, at: 0)
            self.draftTag = ""
        }
        
        return GeometryReader { geometry in
            HStack {
                HStack {
                    CommitImage()
                    TextField(self.$draftTag,
                              placeholder: Text("addTag")
                                .bold()
                                .color(UIColor.systemGray.swiftColor),
                              onEditingChanged: { editing in
                                // Show Close Button
                    },
                              onCommit: applyState)
                        .font(.footnote)
                        .frame(width: min(125, geometry.size.width / 4.0), height: rowHeight)
                        .clipped()
                }
                    .padding(.horizontal)
                    .background(UIColor.secondarySystemBackground.swiftColor)
                    .cornerRadius(8)
                Button(action: applyState) {
                    Image(systemName: "arrow.right.circle.fill")
                        .imageScale(.large)
                        .accessibility(label: Text("Add Tag"))
                }
                TagViewer(tags: self.$tags)
                    .frame(height: rowHeight)
            }
                .frame(height: rowHeight)
        }
    }

One issue I had was dealing with the @State variables. The compiler would complain if the state gets edited in any way from outside the body variable. But I remembered that you can define functions inline, so any state modifying functions I needed could just go in the body! This helped a lot with consolidating logic between multiple triggers. In this code tapping the Add Tag button or hitting return on the keyboard will trigger the applyState() function and save the new tag.

The core of this app is a big text field to let me draft posts, but because of the multi-line bug TextFields in SwiftUI will only show one line, which wasn’t gonna work. What I ended up doing until this gets fixed is wrapping a UITextView with a SwiftUI UIViewRepresentable view. It’s exposed with the very simple TextEditor View, and wraps the TextView_UI view containing and managing the UITextView.


public struct TextEditor: View {
    @Binding public var text: String?
    
    public var body: some View {
        TextField_UI(text: $text)
    }
}
struct TextField_UI : UIViewRepresentable {
    typealias UIViewType = UITextView
    
    @Binding var text: String?
    var onEditingChanged: ((String) -> Void)?
    var onCommit: (() -> Void)?
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.backgroundColor = nil
        textView.text = text ?? ""
        if let font = context.environment.font {
            textView.font = font.font_ui
        } else {
            textView.font = .preferredFont(forTextStyle: .body)
        }
        textView.textContainerInset = .zero
        
        textView.delegate = context.coordinator
        
        return textView
    }
    
    func updateUIView(_ textView: UITextView, context: Context) {
        if let font = context.environment.font, font.font_ui != textView.font {
            textView.font = font.font_ui
        }
        textView.text = text ?? ""
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var field: TextField_UI
        
        init(_ field: TextField_UI) {
            self.field = field
        }
        
        func textViewDidChange(_ textView: UITextView) {
            field.text = textView.text
        }
    }
}

This relied on a few extensions, which map UIColor and UIFont values to SwiftUI’s Color and Font, which I used heavily throughout the app. UIColor now exposes helpful color scheme appropriate colors, and I wanted to use those heavily in the iOS 13 redesign.


extension UIColor {
    var swiftColor: Color {
        var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0
        getRed(&red, green: &green, blue: &blue, alpha: nil)
        return Color(red: Double(red), green: Double(green), blue: Double(blue))
    }
}

For the Font extension, it bothered me that UIFont and Font have totally different values in their TextStyle options, so I made an extension that will do a best effort of finding the right counterpart for the other platform.


extension Font {
    var font_ui: UIFont {
        return UIFont.preferredFont(forTextStyle: assertStyle().style_ui)
    }
    private func assertStyle(_ fallback: TextStyle = .body) -> TextStyle {
        guard let key = TextStyle.allValues.filter({ self == $0.value })
	  .map({ $0.key }).first else { return fallback }
        return key
    }
}
extension Font.TextStyle {
    static var allValues: [Font.TextStyle: Font] {
        return [
            .largeTitle: .largeTitle,
            .title: .title,
            .headline: .headline,
            .subheadline: .subheadline,
            .body: .body,
            .callout: .callout,
            .footnote: .footnote,
            .caption: .caption
        ]
    }
    var style_ui: UIFont.TextStyle {
        switch self {
        case .largeTitle: return .largeTitle
        case .title: return .title1
        case .headline: return .headline
        case .subheadline: return .subheadline
        case .callout: return .callout
        case .footnote: return .footnote
        case .caption: return .caption1
        default: return .body
        }
    }
}

There is obviously a lot more going on in this code, and I may be sharing it on GitHub so people can have a learning resource. I know I appreciated every bit of reference code I could find while putting this app together! But for now the initial impression I have of SwiftUI is that it’s a really clean, modern rethinking of Apple’s platform tools, and one I am very much going to be prioritizing in the next few years.

As for the app, still lots I wanna do obviously. Improving large screen layout, adding the new context menus to the Archive to handle some quick actions, etc. There will be plenty of time for me to explore that, and I can’t wait to get started!

Have a fun summer everyone! It’s gonna be a busy one 😅

Next up to explore is the new SceneDelegate!