Thoughts on SwiftUI vs UIKit

Thoughts on SwiftUI vs UIKit

I had played with SwiftUI sporadically in the past, but it wasn't until now that I had built a complete App Store-ready app with it. When SwiftUI was first announced I was very concerned about certain quirks of the framework and how it was probably not suitable for professional iOS development, but with the framework evolving constantly over the past couple of years it became time to re-evaluate that statement. Through Burnout Buddy, I had the chance to develop a production app 100% with SwiftUI. Here are my thoughts on what works and what could be improved, and how I would fit SwiftUI and UIKit in my day-to-day work.

Things that are great

Previews / Hot Reloading

I can't stress hard enough how awesome SwiftUI's previews are. The amount of time UIKit devs were wasting compiling and running code to make small iterative UI changes is unfathomable -- yes, there were LLDB tricks and tools like Reveal which could sort of let you make certain types of changes on the fly, but I don't know anyone who was actually able to flawlessly integrate these into their day-to-day work. The previews allow you to iterate on visual changes and sometimes even the app's logic itself extremely quickly, and its ability to bootstrap itself out of any arbitrary View pairs perfectly when building composable components.

To be fair, the previews aren't perfect. I had a frequent bug where the preview refused to show up and was unable to do any form of debugging on the preview itself, and a lot of people reached out to say that the previews are painfully slow on their older Macbook models, but it generally works well and I'm very happy we finally have official hot-reloading support in iOS. I'm eager to see how it will be improved in the future.

Development speed

Another extremely notable SwiftUI benefit is how easy it is to build apps with it. You can quite literally build an app to start to finish in just a couple of minutes, something which would be unheard of in any professional-level UIKit app (meaning no Storyboards and similar features that are known for not being scalable).

While Burnout Buddy took me a couple of weeks to build, the majority of that time was spent on the logic and architecture. The UI itself for the entire app was built in just a couple of minutes.

It's important to mention that this is not always true. As we'll see down below there's an entire category of "more complicated" products where SwiftUI is actually extremely detrimental to development speed, but we should still appreciate that SwiftUI works amazingly well for the more straight-forward category of projects that the majority of developers deal with. It's also not too surprising that this is the case considering that they have been Apple's primary target audience for Xcode features since the dawn of iOS.

It encourages great coding practices

It's very easy to make a UIKit app that has everything shoved into one god object. You could theoretically do the same in SwiftUI, but you'd have a much harder doing so. The way views are setup and referenced between each other encourages you to break up your views into small independent pieces, which is an amazing habit to have when building apps of any size as it improves the quality of your project in multiple areas such as composability, scalability, testability and code readability. For developers who are not yet familiar with those concepts, SwiftUI is a great tool to learn and apply those practices.

Things that are not so great

Great for simple things, not so great for not-so-simple things

It's generally easy to build state logic with UIKit. Because there are no constraints on what things should look like, you can build everything on the fly and achieve virtually anything you want at the cost of having to write a lot of annoying UI code.

SwiftUI is the opposite on steroids. It's very, very easy to build UI with, and very, very hard to build any piece of real business logic that you wouldn't see in a simple Hello World tutorial.

My first thought when SwiftUI was announced was that there's no way it works seamlessly for complex apps, and I imagine that anyone who ever had experience with React on the web front had the same thought. The reason for that is because data-drivenness, SwiftUI's main concept that some people already were experienced with due to React functioning roughly in the same way, doesn't scale very well. SwiftUI forces you to shape your model in a way so that the complete structure and state of all views are static and known well in advance, which while not an impossible task, can be insanely difficult for views that have lots of moving parts and conditions.

The best example I can show of how data-drivenness can massively spike the complexity of an app is SwiftUI's ForEach API:

ForEach(0..<7) { i in
    MyObject(myStrings[i])
}

If all you're doing is iterating a static list of elements, everything will work like in UIKit. You can build a loop that takes a fixed integer range and access the indexes without any additional trouble.

Iterating the mutable state of your view is a bit trickier, but still doable. While in UIKit this doesn't incur any extra architectural cost on your behalf, SwiftUI forbids you from doing so unless the content conforms to Identifiable. This can be quite annoying given that almost nothing in Swift's Standard Library conforms to this protocol, but the requirement makes sense in the context of SwiftUI where the framework relies on the concept of model identity and uniqueness in order to determine when the UI should be re-drawn.

ForEach(myStrings) { string in
    MyObject(string)
} // Error: String does not conform to Identifiable

// Solution: Replace "myStrings" with:
struct MyModel: Identifiable {
    let id = ... // How this is implemented depends
    // on what the model represents in practice and how it should be updated
    let string: String
}

Try to do anything more complicated than that though and you'll face the wrath of data-drivenness. Consider the simple use-case of keeping track of the position of an item in a list while iterating it, something which is very common in iOS to implement features like alternating background colors in views: While this is trivial to pull off in UIKit, attempting to introduce dynamic behavior like this in SwiftUI can result in your view's entire architecture being body slammed!

ForEach(contentList.enumerated()) { info in
    MyObject(info.element, info.offset)
} // Error: Arbitrary sequences cannot be iterated in SwiftUI, only RandomAccessCollections

ForEach(Array(strings.enumerated())) { info in
    MyObject(info.element, info.offset)
} // Error: Tuples don't conform to Identifiable, the model must expose some form of unique id

Because the concept of object identity is critical in SwiftUI, if the position of an item is relevant to the UI, we must architecture our view's state model around this fact and provide/predict it in advance alongside everything else that is relevant to the view:

struct ListIndexable<T>: Identifiable {
    let id = ... // How this is implemented depends
    // on what the model represents in practice and how it should be updated
    let index: Int
    let object: T
}

This means that trying to add new behavior to views can result in the entire architecture of that view needing to be refactored, which is a trade-off that developers creating complicated apps would want to avoid at all costs.

But before we continue, we must mention something very important in this area. Astute readers that have used SwiftUI before might look at this example and point out that you don't really need to do any of these, because there's a variation of ForEach that allows you to manually provide the Hashable value that SwiftUI will use deep-down to enforce uniqueness. This allows you to skip the Identifiable requirement and solve the .enumerated() problem with zero extra code, and if you look up this problem in StackOverflow you can even find plug-and-play extensions for this exact use-case:

private func ForEachEnumerated<T: Hashable, U: View>(_ arr: Binding<[T]>, @ViewBuilder content: @escaping (Int, Binding<T>) -> U) -> some View {
    let arr = Array(arr.enumerated())
    return ForEach(arr, id: \.offset, content: content)
}

You need to be very careful when using snippets like this in SwiftUI, because trying to out-smart the framework is a very easy way to end up with cryptic and undebuggable rendering problems. In this case the problem with the snippet is that using the index itself as the definition of uniqueness has serious implications for how SwiftUI will draw updates to that particular list, which might not be what you're expecting for your particular use-case. This is the same reason why I've refrained from implementing let id in the examples above.

When working with frameworks like SwiftUI and React, you should always avoid shortcuts and take the extra time to think how to architect your view's state in a static and predictable way. I've found that this forces you to put quite a lot of pre-thinking into what you want a certain view to do, because if you forget something down the line it can be very hard to recover from it. This won't be a big deal for simple products, but if you're developing a very complicated app, you might not be willing to make that trade-off.

Rendering issues are impossible to debug

Almost nothing you write in SwiftUI is "real" Swift code. With Property Wrappers and Function Builders at its core, almost everything you write will result in the compiler generating additional wiring code for you.

The fact that SwiftUI relies heavily on generated code is not a problem by itself, because as long you know what that wiring code is, you can generally follow along and debug issues just as if that wasn't the case. When the wiring code leads to closed source code however, the story is different.

Here's an example of how this can negatively affect your work. In Burnout Buddy, it's possible to shortcut your way into configuring time schedules for an entire week by setting just one of them and asking the tool to copy the values into the remaining days. In code, I attempted to achieve this by creating a custom Binding object that pastes Monday's schedule into the rest of the week:

let weekdayBinding = Binding<[TimeRange]> {
    schedule.days[0]
} set: { range, transaction in
    schedule.days[0] = range
    schedule.days[1] = range
    schedule.days[2] = range
    schedule.days[3] = range
    schedule.days[4] = range
}

The code above however did not work. To be very specific, it does quite literally nothing. While I could breakpoint the set closure and see that it was being called correctly with the right values, the view was not being updated accordingly. To make things worse, I was also seeing other completely unrelated things being updated as a result of this code that made no sense whatsoever!

The reason it didn't work? I don't know, because Binding is closed source. It took me multiple hours to even realize that this particular piece of code was the source of the problem as there was no feedback from SwiftUI itself that there was something wrong, meaning the only thing I could do was re-write parts of the app aimlessly and hope for the best.

I eventually managed to solve the problem by slightly re-shaping how the closure is executed. Instead of setting each array entry one by one, I chose to copy the entire structure, change it accordingly, and then apply it back to the original bound state object. Because I couldn't actually debug the problem, I can only speculate that the bug has something to do with State not being able to process more than one update in a single stack frame.

let weekdayBinding = Binding<[TimeRange]> {
    schedule.days[0]
} set: { range, transaction in
    var days = schedule.days
    days[0] = range
    days[1] = range
    days[2] = range
    days[3] = range
    days[4] = range
    schedule.days = days
}

This is not a problem with SwiftUI itself, but with Apple deciding to hide critical logic (in this case, what causes a view to re-render) from you. In contrast, you'd not see problems like this in UIKit because the important bits are always handled on your side of the field (e.g when to call addSubview, layoutIfNeeded, etc).

The good news is that SwiftUI is already able to warn you when you make a rendering mistake in certain cases, so Apple is definitely aware of this problem and is working on ways to make it less disruptive.

Should I learn SwiftUI?

Ever since the dawn of software engineering, there have been people engaging in "platform wars". They'll evangelize for the "best architecture", "best framework", "best OS", "best design pattern", and claim that theirs is the only option and everything else is a mistake. This is no different for iOS development, and "SwiftUI vs UIKit" is, sure enough, the new "conflict' of choice.

As a software engineer, it's critical for you to realize that there is no such thing as "best X". Everything is a tool, and whether or not you should use them depends only on what you're trying to achieve. Even Objective-C still has its uses! (If you want more examples of that, I've written a full article about how there is no right or wrong in tech.)

SwiftUI and UIKit do not cancel each other; they are both good and bad in different ways, and whether or not you should pick SwiftUI specifically will depend on the details of what you're trying to build.

After building a SwiftUI app myself, these are the guidelines that I would personally follow for my next projects:

  • Simple UI, Simple Logic: Would make it 100% with SwiftUI.
  • Simple UI, Not-so-simple Logic: I'd start with a 100% SwiftUI app, but if it got too tough I'd fall back to using SwiftUI for the UI itself and UIKit for the more complex pieces of logic (via UIViewRepresentable).
  • Not-so-simple UI, Simple Logic: Will greatly depend on what's being built. I find that more complex pieces of UI can be a lot harder to pull off with SwiftUI when compared to UIKit.
  • Not-so-simple UI, Not-so-simple Logic: Probably best to go 100% UIKit to avoid issues in the long run.

What should beginners learn first?

I think the situation right now is very similar to when Swift was released. I started with iOS exactly when that happened, and the way I learned it was by first learning as much I could about Objective-C and then using that knowledge to seamlessly move over to Swift and making that my main tool of choice. As the years went by and everyone migrated to Swift, it became less and less necessary to strictly learn Objective-C to the point where nowadays you can defer learning it until you reach a point where you want/need to expand your knowledge of the platform itself.

This is how I think beginners should approach SwiftUI vs UIKit. While people will always want to learn the shiny new thing, you should always learn both whilst focusing on what the majority of people use right now to maximize your hiring opportunities. I expect that as the years go the majority of UIKit projects will move over to SwiftUI, but it's still too early to tell if UIKit will completely go away given SwiftUI's current issues when used in more complicated projects.