When Is Swift UI Better Than UIKit? Comparing the Two iOS App Development Choices.

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.

Originally published at https://www.itmagination.com.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
ITMAGINATION

We help our clients innovate by providing professional software engineering and technology advisory services.