SwiftUI: What I learnt making buttons

I am Chris, an iOS Developer here at John Lewis & Partners. I work across our customer facing app, our selection of in-store only apps and our Vapor-enabled APIs.

The engineers at John Lewis & Partners keep a close eye on the future of the technologies we work with. So having played about with SwiftUI, I was eager to learn more and try it out in one of our apps.

What follows is an account of a small part of the things I learnt when I built my first feature using SwiftUI for our in-store ticketing app, focusing on how I got to grips with some of the new APIs and features includingViewModifier, ButtonStyle and @Environment.

Introduction to buttons

So first things first, what is a button in SwiftUI?

struct MainView: View {
var body: some View {
Button(action: {}) {
Text("This is a Button in SwiftUI")
}
}
}

This code snippet will create a view, that contains a button. That button will display some text This is a Button in SwiftUI that when tapped fires off a blank action. It will look like this:

Most apps probably want something a little bit more… unique to their app than the default button. A foreground colour, a background colour, maybe a border? There are a few ways to do this, but after a little bit of research and learning I settled on the use of View Modifiers. A ViewModifier is a way to group together a bunch of modifications to a SwiftUI element that you find yourself repeating. The buttons I created are going to be used all through the app, so this seems the best way to go.

A basic ViewModifier takes a view, and will return a new, modified view. In code that looks something like this:

struct ButtonBackground: ViewModifier {
let color: Color
func body(content: Content) -> some View {
return content.background(color)
}
}

that can be applied like this:

struct MainView: View {
var body: some View {
Button(action: {}) {
Text("This is a modified Button in SwiftUI")
}
.modifier(ButtonBackground(color: .red))
.foregroundColor(.black)
}
}

This shows how ‘standard’ modifiers, like foreground can be applied. This will give you something that looks like:

While you can use any modifier, Apple has provided a specific protocol, ButtonStyle, that you can use to build specific button styles. As the Apple docs say, this will apply “standard interaction behaviour and a custom appearance to all buttons within a view hierarchy. “

They also supply some button styles out the box, which can be looked at here. When building our own, we will want to conform to that protocol too.

The task at hand

I wanted to build buttons that fit in with an existing UIKit app. These buttons should:

  • match the styles of the UIKit buttons we’ve built previously
  • have enabled, disabled, loading and tapped states
  • be easy to reuse across the app

Building a button up

To conform our new button style to the protocol ButtonStyle all we need is a function makeBody(configuration: Self.Configuration) -> Some View .

The first thing I built was this:

struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.foregroundColor(.white)
.background(Color.black)
.font(.title)
.frame(maxWidth: .infinity)
.padding()
}
}

This sets a foreground and background colour, a font, the width of the frame and applies padding around the button. This has defined the basic look of our button. A great start.

isPressed

It’s good to give a user feedback when they interact with your app, so I wanted to use the isPressed state to change the look of the button. Luckily the configuration that is available in the makeBody function enables this.

struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
if configuration.isPressed {
return configuration.label
.foregroundColor(.white)
.background(Color.gray)
.frame(maxWidth: .infinity)
.padding()
}
return configuration.label
.foregroundColor(.white)
.background(Color.black)
.frame(maxWidth: .infinity)
.padding()
}
}

Which ends up giving us something like this:

Problem number 1 — disabled state

So this was my first major stumbling block. How could I handle disabled state?

This requires a little thought about how the View Hierarchy in SwiftUI works in tandem with the @Environment property wrapper.

A view can be disabled using the .disabled(true) function. This sets an environment property isEnabled on the view it is added to, but also all child views as well.

While Views in SwiftUI have access to the @Environment property, the ButtonStyle as we have so far defined it doesn’t have access to them. So where to go from here?

A ViewModifier is cool — it returns a view. When you write configuration.label this is also a view. If we start thinking about ViewModifiers as things that return views we can start to make use of environment properties.

Views within ViewModifiers

I want my button to look different between an enabled state, a pressed state and a default state. I built my view modifier and after a bit of refactoring this is what I ended up with.

struct PrimaryButtonStyle: ButtonStyle {
struct Content: View {
@Environment(\.isEnabled) var isEnabled
let configuration: Configuration
var label: some View {
configuration.label.frame(maxWidth: .infinity).padding()
}
var body: some View {
Group {
if configuration.isPressed {
label
.foregroundColor(Color.black)
.background(Color.gray)
} else if isEnabled {
label
.foregroundColor(Color.white)
} else {
label
.background(Color.green)
}
}
}
}
func makeBody(configuration: Self.Configuration) -> some View {
Content(configuration: configuration)
}
}

Well… not quite this, but it’s enough to illustrate a point. You can see three different states, with 3 different sets of colours being applied to the isPressed, the isEnabled and ‘normal’ state.

If I just tried to return each label, like this:

struct PrimaryButtonStyle: ButtonStyle {
struct Content: View {
@Environment(\.isEnabled) var isEnabled
let configuration: Configuration
var label: some View {
configuration.label.frame(maxWidth: .infinity).padding()
}
var body: some View {
if configuration.isPressed {
return label
.foregroundColor(Color.black)
.background(Color.gray)
} else if isEnabled {
return label
.foregroundColor(Color.white)
} else {
return label
.background(Color.green)
}
}
}
func makeBody(configuration: Self.Configuration) -> some View {
Content(configuration: configuration)
}
}

I’d end up with a compile error:

Function declares an opaque return type, but the return statements in its body do not have matching underlying types

This is because the there are 3 different Views that the body could return, and they all have different types.

One of the things I struggled with when first using SwiftUI was the way that those modifiers change the View type. For example Text("").padding() isn’t the same Type as Text(""). It gets even more complicated when you start adding if statements into something that returns some View. Every if statement has to return the exact same type.

Problem 2 — Maintaining isPressed state

The code above results in a single view type along the lines of Group_ConditionalContent…. I was using Group because I wanted to be able to return views with different types in each if statement. This way, I thought, the View inside each if could be any Type i.e have different modifiers applied in the if statements. However, this caused some behaviour I didn’t expect. I won’t call it a bug (thanks to a very useful Feedback¹ I now realise I was experiencing expected behaviour, and why I was experiencing it) but at first it felt like one.

It isn’t obvious from this gif, but the previous code results in the button only briefly showing the pressed state. I wanted something that stayed in the pressed state for as long as the user was pressing on the button. So what’s going on?

After a bit of experimentation I realised that it was a combination of if/else and Group at play that was causing this behaviour. When using if/else in the group the whole View gets rendered twice while pressing the button, whereas removing the Group and returning separately seems to only render once when pressed, and again when the pressing stops.

Using a Group creates a type along the lines of Group_ConditionalContent… . This is a single type, which includes the types of each of the views in the if statements.

My Apple Feedback explains this in a better way. When the isPressed condition changes the View, an entirely different view is swapped in, one that isn’t pressed… so the isPressed condition is now false, and a new version of the unpressed view is switched in. Everything gets reset when using a Group.

Group is a transparent container, representing a collection of views without a container. What that means is

MyView() 
Group { MyView() }

are always functionally equivalent in SwiftUI, no matter where they are used or how they are modified.

There is a really simple change, though, that makes this work. The body of my ViewModifier becomes something like:

struct PrimaryButtonStyle: ButtonStyle {    struct Content: View {
@Environment(\.isEnabled) var isEnabled
let configuration: Configuration

var body: some View {
VStack {
if !isEnabled {
configuration.label
.foregroundColor(.white)
.background(Color.gray)
} else if configuration.isPressed {
configuration.label
.foregroundColor(.white)
.background(Color.red)
} else {
configuration.label
.background(Color.blue)
}
}
}
}
func makeBody(configuration: Self.Configuration) -> some View {
Content(configuration: configuration)
}
}

The change is small — all I have done is swap out the Group for a VStack. Using the VStack (and I’ll quote from the Feedback here) creates a ‘persistent intermediary view between the conditional content and the gesture’.

This solves the problem — now when I press the button is stays pressed until I let it go! It also still keeps track of the enabled state, and allows the different returns Views to be different types.

The last thing to solve — isLoading

Having built a PrimaryButtonStyle and a SecondaryButtonStyle I had one thing left to do — have some sort of feedback when a user has triggered an asynchronous action. A spinner is a great way of doing this.

Having spent a lot of time ‘in the weeds’ my first attempt at this was to use @Environment again, this time defining my own isLoading that I could pass through to my ButtonStyle. In the end though (and with a bit of collaboration with members of my team) I came up with a slightly different way of dealing with this use case.

Instead of using an @Environment variable, I created an @State variable on the page showing the button, and extended View to have a .overlay() function:

extension View {
func overlayIf<V: View>(bool: Bool, @ViewBuilder content: () -> V) -> some View {
Group {
if bool {
self.overlay(content())
} else {
self
}
}
}
}

which is used like so:

var button: some View {
Button(action: {}) {
Text("A loading button")
}
.buttonStyle(PrimaryButtonStyle())
.overlayIf(isLoading) {
Color.gray.overlay(
ActivityIndicator(isAnimating: .constant(true)))
}
}

(here activity indicator is a SwiftUI UIViewRepresentable of a UIActivityIndicatorView).

There are a few benefits to this approach. One is a separation of concerns: the the ButtonStyle handles the styling of the button and the loading state is handled in a separate view. This feels like how SwiftUI wants us to work. Another is that the content only gets created if the condition is true. Finally, it has the added benefit of ‘disabling’ user interaction with the button while something is loading — because the overlay is a new view on top of the button, the user cannot hit it again and again while a network call is being made.

Summary

When starting out I really liked SwiftUI, and having built a feature in it for our production apps I still do.

I started out saying we needed a button that:

  • matches the styles of the UIKit buttons we’ve built previously
  • has enabled, disabled, loading and tapped states
  • be easy to reuse across the app

and, all in all, that’s what we came out with. There is still some refactoring I can think of doing, but the important thing is that this process allowed me to experiment with new technology but still deliver something of value, whether that was working code or knowledge that we didn’t want to go down this route quite yet. The team were fully behind the idea of throwing it away and starting again in UIKit if we didn’t think it was working.

Happily, building something in SwiftUI actually reduced the time it took to build the feature and increased the collaboration between UI/UX and developers (it was a new tool and we were testing how easy it was to do things so we went back and forth all the time).

The approach of building up something piece by piece was important. Not all buttons needed a loading state. Not all buttons needed disabling. This meant I was able to get parts of the feature out in front of users before completing the whole piece and get feedback from them as we built it.

Was it worth it? I can honestly say it was. I built something in an internal app, so I realise there where some hurdles I didn’t have to overcome (the user base were all on iOS 13 for example) but SwiftUI is really beautiful to use, and I encourage you all to try it out.

Footnotes

  1. Feedback — Developers can raise queries about bugs through the use of Apple’s Feedback Assistant

--

--

Chris Thomas
John Lewis Partnership Software Engineering

I’m currently a lead iOS Developer working for John Lewis & Partners.