Neomorphism using SwiftUI. Part 2: What can be done with accessibility?

7 min


Hello everyone! Ahead of the start of the advanced course “IOS Developer” we publish a translation of the second part of the article about neomorphism using SwiftUI (read the first part).


Dark theme

Before we begin to understand how we can improve the availability of neomorphism, let’s see how we can work with the effect to create other interesting styles.

First add two more colors to the extension Colorso that we have at hand a few constant values:

static let darkStart = Color(red: 50 / 255, green: 60 / 255, blue: 65 / 255)
static let darkEnd = Color(red: 25 / 255, green: 25 / 255, blue: 30 / 255)

We can use them as background for ContentViewreplacing existing Color.whiteas shown here:

var body: some View {
    ZStack {
        LinearGradient(Color.darkStart, Color.darkEnd)

Our SimpleButtonStyle Now it looks inappropriate because it imposes a bright stylization on a dark background. So, we’re going to create a new dark style that works better here, but this time we will divide it into two parts: a background view that we can use anywhere, and a button style that wraps it with the padding and contentShape modifiers. This will give us more flexibility, as you will see later.

The new background view that we are going to add will allow us to specify any shape for our visual effect, so we are no longer attached to circles. It will also keep track of whether to draw concave our convex effect (in or out) depending on the property isHighlightedwhich we can change from the outside.

We will start with the simplest, using a modified shadow flip approach to get a concave effect. Add this structure:

struct DarkBackground: View {
    var isHighlighted: Bool
    var shape: S

    var body: some View {
        ZStack {
            if isHighlighted {
                shape
                    .fill(Color.darkEnd)
                    .shadow(color: Color.darkStart, radius: 10, x: 5, y: 5)
                    .shadow(color: Color.darkEnd, radius: 10, x: -5, y: -5)

            } else {
                shape
                    .fill(Color.darkEnd)
                    .shadow(color: Color.darkStart, radius: 10, x: -10, y: -10)
                    .shadow(color: Color.darkEnd, radius: 10, x: 10, y: 10)
            }
        }
    }
}

The modification is that when the button is pressed, the size of the shadow decreases – we use a distance of 5 points instead of 10.

Then we can wrap it with DarkButtonStylewhich applies padding and contentShape, as shown here:

struct DarkButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .padding(30)
            .contentShape(Circle())
            .background(
                DarkBackground(isHighlighted: configuration.isPressed, shape: Circle())
            )
    }
}

Finally, we can apply this to our button in ContentView by changing her ButtonStyle():

.buttonStyle(DarkButtonStyle())

Let’s see what happened – although we don’t have much code, I think the result looks good enough.

Some experiments

Now is the time to experiment with the effect, because it will help you better understand what SwiftUI is specifically capable of.

For example, we could create a smooth convex button by adding a linear gradient to it and flip it when pressed:

if isHighlighted {
    shape
        .fill(LinearGradient(Color.darkEnd, Color.darkStart))
        .shadow(color: Color.darkStart, radius: 10, x: 5, y: 5)
        .shadow(color: Color.darkEnd, radius: 10, x: -5, y: -5)

} else {
    shape
        .fill(LinearGradient(Color.darkStart, Color.darkEnd))
        .shadow(color: Color.darkStart, radius: 10, x: -10, y: -10)
        .shadow(color: Color.darkEnd, radius: 10, x: 10, y: 10)
}

If you run this, you will see that the button smoothly animates up and down when pressed and released. I find the animation a little distracting, so I recommend disabling it by adding this modifier to the method makeBody() of DarkButtonStyleafter the modifier present there background():

.animation(nil)

This effect of the cushion button is charming, but if you plan to use it, I would advise you to try the following three changes to make the button stand out a little more.

Firstly, despite the fact that this contradicts the low-contrast principle of neomorphic design, I would replace the gray icon with a white one to make it stand out. So in ContentView you would need the following:

Image(systemName: "heart.fill")
    .foregroundColor(.white)

Secondly, if you add overlay for the button in the pressed state, this will not only make it look more like a real physical button pressed evenly, but will also help to distinguish its pressed state from the unpressed one.

To implement this, you need to insert a modifier overlay() after fill()when isHighlighted true, like here:

if isHighlighted {
    shape
        .fill(LinearGradient(Color.darkEnd, Color.darkStart))
        .overlay(shape.stroke(LinearGradient(Color.darkStart, Color.darkEnd), lineWidth: 4))
        .shadow(color: Color.darkStart, radius: 10, x: 5, y: 5)
        .shadow(color: Color.darkEnd, radius: 10, x: -5, y: -5)

To achieve an even sharper look, you can remove two modifiers shadow() for pressed state that focus on overlay otherwise.

Thirdly, you can also add overlay to the unpressed state, just to mark that it is a button. Place it immediately after fill()for example like this:

} else {
    shape
        .fill(LinearGradient(Color.darkStart, Color.darkEnd))
        .overlay(shape.stroke(Color.darkEnd, lineWidth: 4))
        .shadow(color: Color.darkStart, radius: 10, x: -10, y: -10)
        .shadow(color: Color.darkEnd, radius: 10, x: 10, y: 10)
}

Adding a Switch Style

One of the advantages of separating a button style from a non-morphic background style is that we can now add a switch style using the same effect. This means creating a new protocol-compliant structure. ToggleStylewhich looks like ButtonStyle, except that:

  1. We must read configuration.isOnto determine if the switch is on.
  2. We need to provide a button to handle the act of switching, or at least something like onTapGesture() or in that spirit.

Add this structure to your project:

struct DarkToggleStyle: ToggleStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        Button(action: {
            configuration.isOn.toggle()
        }) {
            configuration.label
                .padding(30)
                .contentShape(Circle())
        }
        .background(
            DarkBackground(isHighlighted: configuration.isOn, shape: Circle())
        )
    }
}

We want to put one of them in ContentViewso that you can test it yourself, so start by adding this property:

@State private var isToggled = false

Then wrap the existing button in VStack with spacing equal to 40 and place it below:

Toggle(isOn: $isToggled) {
    Image(systemName: "heart.fill")
        .foregroundColor(.white)
}
.toggleStyle(DarkToggleStyle())

Your structure ContentView should look like this:

struct ContentView: View {
    @State private var isToggled = false

    var body: some View {
        ZStack {
            LinearGradient(Color.darkStart, Color.darkEnd)

            VStack(spacing: 40) {
                Button(action: {
                    print("Button tapped")
                }) {
                    Image(systemName: "heart.fill")
                        .foregroundColor(.white)
                }
                .buttonStyle(DarkButtonStyle())

                Toggle(isOn: $isToggled) {
                    Image(systemName: "heart.fill")
                        .foregroundColor(.white)
                }
                .toggleStyle(DarkToggleStyle())
            }
        }
        .edgesIgnoringSafeArea(.all)
    }
}

And that’s all – we applied our common neomorphic design in two places!

Accessibility improvement

We had enough time to play with various neomorphic styles, but now I want to dwell on the problems of this design: an extreme lack of contrast means that buttons and other important controls are not enough to stand out from their surroundings, which makes it difficult to use our applications for people with imperfect vision.

This is the moment around which I observe some misunderstanding, so I want to say a few things in advance:

  1. Yes, I understand that standard Apple buttons look just like blue text and therefore do not resemble familiar buttons, at least at first glance, but they have a high contrast ratio.
  2. It’s not enough to say “I like it so much, but I can add a special option to make it more accessible” – accessibility is not a “nice bonus”, it is a requirement, therefore our applications should be accessible by default, and not by executing any procedures.
  3. The controls we created are still SwiftUI buttons and switches, which means that all our changes will not affect their visibility or functionality for VoiceOver or other Apple assistive technologies.

You have already seen how we changed the gray icon to white to get instant contrast enhancement, but the buttons and switches still need much more contrast if you want to make them more accessible.

So, we are going to consider what changes we could make so that the elements really stand out.

First, I would like you to add two new colors to our extension:

static let lightStart = Color(red: 60 / 255, green: 160 / 255, blue: 240 / 255)
static let lightEnd = Color(red: 30 / 255, green: 80 / 255, blue: 120 / 255)

Second, duplicate the existing DarkBackground and name the copy ColorfulBackground. We will deal with it in a moment, but again, first we need to do some preparation.

Third, duplicate the dark style of the button and switch, rename them to ColorfulButtonStyle and ColorfulToggleStyleand then get them to use the new ColorfulBackground as a background.

So they should look like this:

struct ColorfulButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .padding(30)
            .contentShape(Circle())
            .background(
                ColorfulBackground(isHighlighted: configuration.isPressed, shape: Circle())
            )
            .animation(nil)
    }
}

struct ColorfulToggleStyle: ToggleStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        Button(action: {
            configuration.isOn.toggle()
        }) {
            configuration.label
                .padding(30)
                .contentShape(Circle())
        }
        .background(
            ColorfulBackground(isHighlighted: configuration.isOn, shape: Circle())
        )
    }
}

Finally, edit the button and switch in ContentViewso that they use the new style:

Button(action: {
    print("Button tapped")
}) {
    Image(systemName: "heart.fill")
        .foregroundColor(.white)
}
.buttonStyle(ColorfulButtonStyle())

Toggle(isOn: $isToggled) {
    Image(systemName: "heart.fill")
        .foregroundColor(.white)
}
.toggleStyle(ColorfulToggleStyle())

You can run the application if you want, but that makes no sense – it has not actually changed.

To bring our colorful version to life, we’re going to change the modifiers fill() and overlay() for pressed and non-pressed states. So when isHighlighted true, change darkStart and darkEnd on the lightStart and lightEnd, like this:

if isHighlighted {
    shape
        .fill(LinearGradient(Color.lightEnd, Color.lightStart))
        .overlay(shape.stroke(LinearGradient(Color.lightStart, Color.lightEnd), lineWidth: 4))
        .shadow(color: Color.darkStart, radius: 10, x: 5, y: 5)
        .shadow(color: Color.darkEnd, radius: 10, x: -5, y: -5)

If you run the application again, you will see that it has already improved significantly: the pressed state now has a bright blue color, so it becomes clear when the buttons are pressed and the switches are active. But we can do something else – we can add the same color around the button when it is not pressed, helping to draw attention to it.

To do this, modify the existing overlay() for a non-stressed state on this:

.overlay(shape.stroke(LinearGradient(Color.lightStart, Color.lightEnd), lineWidth: 4))

So, the finished button style should look like this:

ZStack {
    if isHighlighted {
        shape
            .fill(LinearGradient(Color.lightEnd, Color.lightStart))
            .overlay(shape.stroke(LinearGradient(Color.lightStart, Color.lightEnd), lineWidth: 4))
            .shadow(color: Color.darkStart, radius: 10, x: 5, y: 5)
            .shadow(color: Color.darkEnd, radius: 10, x: -5, y: -5)
    } else {
        shape
            .fill(LinearGradient(Color.darkStart, Color.darkEnd))
            .overlay(shape.stroke(LinearGradient(Color.lightStart, Color.lightEnd), lineWidth: 4))
            .shadow(color: Color.darkStart, radius: 10, x: -10, y: -10)
            .shadow(color: Color.darkEnd, radius: 10, x: 10, y: 10)
    }
}

Now run the application again, and you will see that a blue ring has appeared around the buttons and switches, and when clicked it is filled with blue – it is much more accessible.

Conclusion

In practice, you will not have several different button styles in one project, at least if you do not like to create a headache for your users. But this is an interesting space for experimentation, and I hope that you catch this idea of ​​my article, because you can create really beautiful effects without tearing your back.

I have repeatedly said that you should always monitor the availability of your application, and that means more than just making sure VoiceOver works with your user interface. Make sure your buttons look interactive, make sure your text labels and icons have a sufficient contrast ratio to the background (at least 4.5: 1, but tend to 7: 1), and make sure your clickable areas are comfortable and large (at least 44×44 pixels).

And for heaven’s sake, use the neomorphic design to experiment and expand your knowledge of SwiftUI, but never forget that if you sacrifice usability for a new fashion design trend, you won nothing.

You can get the full source code for this project at GithubYu

Read the first part.


Learn more about the course.



0 Comments

Leave a Reply