A Week With SwiftUI
June 7, 2019
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.
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.
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 💯
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.
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
!