There is no better time than now to write about the reasoning behind using Swift UI or UI Kit in your native iOS apps. Why now? iOS 16 brings many new, exciting features. What’s more important, is that it gave us developers all the goodies that come with the latest iteration of the Swift UI; we can use the latest APIs that were recently unavailable.
Lucky Coincidence? A Well-Planned Strategy?
You may call it a lucky coincidence or well-planned strategic decisions by Apple engineers when crafting the complete update experience, but the reality is that iOS users tend to keep their devices updated more than not. That’s important because it defines what APIs can be used and keeps the balance between supporting older OS versions and being efficient at developing with latest APIs. The fragmentation of system versions is not as visible as it used to be (and still is) in the Android world.
Obviously, not all users update their devices the same day an update is available (one aspect is the bugs, another is not everybody has the mind or the internet connection for it). That would have been too good to be true; at least for us, developers. That’s why we rarely see an app that supports just the latest version, especially if we’re still freshly after the date the update was made available.
From our experience, apps usually support the latest 2 major releases of the operating system, i.e., iOS 15 and iOS 14. The time in the fall after Apple publishes the new major OS update is always the time when we would drop the oldest version and (eventually) clean up the code to go with the new set of the latest features supported by the last 2 major OS versions. For example, following that logic now we’re getting into the period where it is time to increment the minimum OS version needed to run our apps to iOS 15. Keep in mind, the decision about what’s the minimum version that we should keep supporting was always made by looking at usage statistics for the app. It’s just that repeatedly, year over year, we were finding out that eventually in the fall most users were running one of the 2 latest iOS versions. If most of your users are still using the iOS version from 3 years ago, don’t drop the support for them.
That’s good news for Swift UI as when targeting iOS 14 there are some significant APIs missing, and we would easily hit major obstacles if we wanted to do “too much” with Swift UI and still wanted to support iOS 14.
We’ll show some code examples about how we could implement some features that would be not worth the effort if we wanted to create those features in the declarative Swift framework. On the other hand, iOS 15 provided solutions to all those listed issues, and we will demonstrate how a solution would be created with Swift UI.
If our job was to create a UI element that was supposed to display a text, where every character could have its own attributes, we would choose UI Kit when targeting iOS14. Since now, we can drop iOS14 and keep only iOS15 and iOS16, we may safely use Swift UI. The code examples below show how to achieve more-or-less the same result, but first using Swift UI, and then using UIKit.
Let's dive in.
Examples
Let's start off with a simple task.
Creating a Text View Where the Text Is Colored as a Rainbow, Every Character in Its Own Color.
Swift UI
The ContentView implementation
And the RainbowText implementation
import SwiftUI
struct RainbowText: View {
private var attributedString: AttributedString
private static let colors: [Color] = [
.rainbowRed,
.rainbowOrange,
.rainbowYellow,
.rainbowGreen,
.rainbowBlue,
.rainbowIndigo,
.rainbowViolet
]
var body: some View {
Text(attributedString)
}
init(withString string: String) {
self.attributedString = RainbowText.annotateRainbowColors(from: string)
}
private static func annotateRainbowColors(from source: String) -> AttributedString {
var attrString = AttributedString(stringLiteral: source)
var characterIndex = attrString.startIndex
let rainbowColors = RainbowText.colors
var colorCounter = 0
while characterIndex < attrString.endIndex {
let nextIndex = attrString.characters.index(characterIndex, offsetBy: 1)
attrString[characterIndex ..< nextIndex].foregroundColor = rainbowColors[colorCounter]
colorCounter += 1
if colorCounter >= rainbowColors.count {
colorCounter = 0
}
characterIndex = nextIndex
}
return attrString
}
}
UIKit
For the sake of comparison, here’s a UIKit implementation for a similar UI component:
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var label: UILabel!
let text = "This rainbow is so fun!!!!"
override func viewDidLoad() {
super.viewDidLoad()
label.attributedText = rainbowText(from: text)
}
func rainbowText(from text: String) -> NSAttributedString {
let attrString = NSMutableAttributedString(string: text)
let rainbowColors = Rainbow.colors
var index = 0
var colorCounter = 0
while index < attrString.length {
let range = NSRange(location: index, length: 1)
attrString.addAttribute(.foregroundColor, value: rainbowColors[colorCounter], range: range)
colorCounter += 1
if colorCounter >= rainbowColors.count {
colorCounter = 0
}
index += 1
}
return attrString
}
}
Navigation on an iPad
Here’s an example with navigation, UIKit vs. SwiftUI (iPad-specific)
Swift UI
import SwiftUI
struct MyView: View {
struct Item: Identifiable {
let id: Int
}
var items = [1, 2, 3, 7].map(Item.init(id:))
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Detail view for item \(item.id)")
} label: {
Text("Item \(item.id)")
}
}
}
}.navigationViewStyle(.columns)
}
}
UIKit
import UIKit
final class BasicCell: UITableViewCell {
let label = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
let marginsGuide = contentView.layoutMarginsGuide
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
NSLayoutConstraint.activate([
label.widthAnchor.constraint(equalTo: marginsGuide.widthAnchor),
label.heightAnchor.constraint(equalTo: marginsGuide.heightAnchor),
label.centerXAnchor.constraint(equalTo: marginsGuide.centerXAnchor),
label.centerYAnchor.constraint(equalTo: marginsGuide.centerYAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
final class RootViewController: UITableViewController {
var items = [1, 2, 3, 7] {
didSet {
tableView.reloadData()
}
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(BasicCell.self, forCellReuseIdentifier: "BasicCell")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "BasicCell", for: indexPath)
(cell as? BasicCell)?.label.text = "Item \(items[indexPath.row])"
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let viewController = DetailViewController()
viewController.item = items[indexPath.row]
splitViewController?.setViewController(UINavigationController(rootViewController: viewController), for: .secondary)
splitViewController?.show(.secondary)
}
}
final class DetailViewController: UIViewController {
var item: Int?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = item.map { "Detail view for item \($0)" }
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}
// ...
let viewController = UISplitViewController(style: .doubleColumn)
viewController.setViewController(RootViewController(), for: .primary)
viewController.preferredDisplayMode = .oneBesideSecondary
viewController.preferredSplitBehavior = .displace
viewController.show(.primary)
viewController.modalPresentationStyle = .fullScreen
present(viewController, animated: true)
CoreData & Two-Directional Collection View
Do notice the difference in the length between the implementations.
Swift UI
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [SortDescriptor(\.name, order: .forward)],
animation: .default)
private var items: FetchedResults<ShoppingListItem>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
HStack {
Text(item.name!)
Spacer()
Text(item.quantity.map { "\($0)" } ?? "")
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
}
private func addItem() {
withAnimation {
let newItem = ShoppingListItem(context: viewContext)
newItem.name = ["Apples", "Oranges", "Bananas"].randomElement()
newItem.quantity = (2...10).randomElement().map { Decimal($0) as NSDecimalNumber }
do {
try viewContext.save()
} catch {
// Handle errors
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Handle errors
}
}
}
}
UIKit
import UIKit
import CoreData
final class ItemCell: UITableViewCell {
let nameLabel = UILabel()
let quantityLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
quantityLabel.setContentHuggingPriority(.required, for: .horizontal)
quantityLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let stackView = UIStackView(arrangedSubviews: [nameLabel, quantityLabel])
let marginsGuide = contentView.layoutMarginsGuide
stackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.widthAnchor.constraint(equalTo: marginsGuide.widthAnchor),
stackView.heightAnchor.constraint(equalTo: marginsGuide.heightAnchor),
stackView.centerXAnchor.constraint(equalTo: marginsGuide.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: marginsGuide.centerYAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class ViewController: UITableViewController {
struct Item {
let id: NSManagedObjectID
let name: String
let quantity: Decimal?
init?(fetched: ShoppingListItem) {
guard let name = fetched.name else { return nil }
self.id = fetched.objectID
self.name = name
self.quantity = fetched.quantity as Decimal?
}
}
var items: [Item] = [] {
didSet {
tableView.reloadData()
}
}
private var viewContext: NSManagedObjectContext { (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext }
private lazy var defaultBarItems: [UIBarButtonItem] = [
.init(barButtonSystemItem: .edit, target: self, action: #selector(editButtonTapped)),
.init(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped))
]
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItems = defaultBarItems
tableView.register(ItemCell.self, forCellReuseIdentifier: "ItemCell")
loadItems()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ItemCell", for: indexPath)
if let cell = cell as? ItemCell {
let item = items[indexPath.row]
cell.nameLabel.text = item.name
cell.quantityLabel.text = item.quantity.map { "\($0)" }
}
return cell
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else {
return
}
let id = items.remove(at: indexPath.row).id
let viewContext = viewContext
viewContext.perform {
viewContext.delete(viewContext.object(with: id))
do {
try viewContext.save()
} catch {
// Handle errors
}
}
}
private func loadItems() {
let viewContext = viewContext
viewContext.perform { [weak self] in
do {
self?.items = try viewContext.fetch(ShoppingListItem.fetchRequest()).compactMap(Item.init(fetched:))
} catch {
// Handle errors
}
}
}
@objc
private func addButtonTapped() {
let viewContext = viewContext
viewContext.perform {
let object = ShoppingListItem(context: viewContext)
object.name = ["Apples", "Oranges", "Bananas"].randomElement()
object.quantity = (2...10).randomElement().map { Decimal($0) as NSDecimalNumber }
do {
try viewContext.save()
} catch {
// Handle errors
}
}
loadItems()
}
@objc
private func editButtonTapped() {
tableView.setEditing(true, animated: true)
navigationItem.rightBarButtonItem = .init(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped))
}
@objc
private func doneButtonTapped() {
tableView.setEditing(false, animated: true)
navigationItem.rightBarButtonItems = defaultBarItems
}
}
In the projects from the past five years, it's hard to find an edge case that would make using UIKit a better choice. Surely, there still are completely valid use cases where it makes sense implementing a specific UI feature with UIKit. If a few years ago you wanted to have an app that’s mostly built with UIKit, and occasionally you would include a Swift UI view in just some parts of the UI; as a component wrapped in a container. Currently, we could say that we have the opposite situation: we should be able to build apps efficiently using the new framework and eventually if we run into an edge case where a Swift UI implementation would exceed the expected complexity, then you implement that component using UIKit and just wrap it with a Swift UI view.
Closing Words
One last thing; Swift UI's learning curve. Probably the majority of us would agree it’s quite a gentle learning curve. Especially when looking at Swift UI demo videos from Apple or following some tutorials, the learning curve seems even gentler - you can easily achieve all the results you want...
...except when you don’t. Until you get enough mileage, it’s easy to make an error and spend hours to find a solution and eventually giving up and falling back to good old UIKit. The better you know it, the easier it is to fall back to it, too. But the time is right, embrace Swift UI with open hands, it won’t bite. Alternatively, if you need help migrating to SwiftUI, contact us, and we will be glad to help.