Neomorphism using SwiftUI. Part 1

Salute, Khabrovites! Ahead of the start of the advanced course “IOS Developer” we have prepared another interesting translation.


Non-morphic design is perhaps the most interesting trend of recent months, although, in truth, Apple used it as its design motif at WWDC18. In this article, we will look at how you can implement a non-morphic design using SwiftUI, why you might want to do this, and – most importantly – how we can refine this design to increase its accessibility.

Important: Neomorphism – also sometimes called neuromorphism – has serious consequences for accessibility, so despite the temptation to read the first part of this article and skip the rest, I urge you to read the article to the end and study both the advantages and disadvantages so that you can see the full picture.

Basics of Neomorphism

Before we move on to the code, I want to briefly outline two basic principles of this direction in design, since they will be relevant as we move along:

  1. Neomorphism uses glare and shadow to determine the shapes of objects on the screen.
  2. Contrast tends to decrease; completely white or black are not used, which allows you to highlight highlights and shadows.

The end result is a look reminiscent of “extruded plastic” – a user interface design that certainly looks fresh and interesting without bumping into your eyes. I cannot but repeat once again that reducing contrast and using shadows to highlight shapes seriously affects accessibility, and we will return to this later.

However, I still think the time spent learning about neomorphism in SwiftUI is worth it – even if you don’t use it in your own applications, it’s like a code writing kata to help hone your skills.

Alright, enough idle talk – let’s move on to the code.

Building a non-morphic map

The simplest starting point is to create a non-morphic map: a rounded rectangle that will contain some information. Next, we’ll look at how we can transfer these principles to other parts of SwiftUI.

Let’s start by creating a new iOS project using the Single View app template. Make sure you use SwiftUI for the user interface, and then name the project Neumorphism.

Tip: if you have access to preview SwiftUI in Xcode, I recommend that you activate it right away – it will be much easier for you to experiment.

We’ll start by defining a color that represents a creamy hue. This is not pure gray, but rather a very subtle shade that adds a little warmth or coolness to the interface. You can add it to the asset directory if you want, but now it’s easier to do in code.

Add this Color out of structure ContentView:

extension Color {
    static let offWhite = Color(red: 225 / 255, green: 225 / 255, blue: 235 / 255)
}

Yes, it’s almost white, but it’s dark enough to make real white look like a glow when we need it.

Now we can fill the body ContentViewby providing him ZStackthat takes up the entire screen, using our new quasi-white color to fill the entire space:

struct ContentView: View {
    var body: some View {
        ZStack {
            Color.offWhite
        }
        .edgesIgnoringSafeArea(.all)
    }
}

To represent our map, we will use a rounded rectangle in the resolution of 300×300 to make it beautiful and clear on the screen. So add this to ZStack, under the color:

RoundedRectangle(cornerRadius: 25)
    .frame(width: 300, height: 300)

By default it will be black, but for the implementation of the neomorphism we want to sharply reduce the contrast, so we will replace it with the same color that we use for the background, in fact making the shape invisible.

So change it like this:

RoundedRectangle(cornerRadius: 25)
    .fill(Color.offWhite)
    .frame(width: 300, height: 300)

An important point: we define the shape using shadows, one dark and one light, as if the light cast rays from the upper left corner of the screen.

SwiftUI allows us to apply modifiers several times, which facilitates the implementation of neomorphism. Add the following two modifiers to your rounded rectangle:

.shadow(color: Color.black.opacity(0.2), radius: 10, x: 10, y: 10)
.shadow(color: Color.white.opacity(0.7), radius: 10, x: -5, y: -5)

They represent the offset of the dark shadow in the lower right corner and the offset of the light shadow in the upper left corner. The light shadow is visible because we used a quasi-white background, and now the map becomes visible.

We have written just a few lines of code, but we already have a non-morphic map – I hope you will agree that SwiftUI surprisingly facilitates the process!

Creating a simple non-morphic button

Of all the elements of a UI, neomorphism poses a fairly low risk for cards – if the UI inside your cards is clear, the card may not have a clear border, and this will not affect accessibility. Buttons are another matter, because they are designed to interact, so reducing their contrast can do more harm than good.

Let’s deal with this by creating our own button style, as this is the way that SwiftUI allows button configurations to be distributed in many places. This is much more convenient than adding many modifiers to each button you create – we can simply define the style once and use it in many places.

We are going to define a button style that will actually be empty: SwiftUI will give us the label for the button, which may be text, image, or something else, and we will send it back without changes.

Add this structure somewhere outside ContentView:

struct SimpleButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
    }
}

This configuration.label – this is what holds the contents of the button, and soon we will add something else there. First, let’s define a button that uses it so you can see how the design is evolving:

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

You won’t see anything special on the screen, but we can fix it by adding our non-morphic effect to the button style. This time we will not use a rounded rectangle, because for simple icons the circle is better, but we need to add some indentation so that the button click area is large and beautiful.
Change your method makeBody()by adding some indentation and then placing our non-morphic effect as the background for the button:

configuration.label
    .padding(30)
    .background(
        Circle()
            .fill(Color.offWhite)
            .shadow(color: Color.black.opacity(0.2), radius: 10, x: 10, y: 10)
            .shadow(color: Color.white.opacity(0.7), radius: 10, x: -5, y: -5)
    )

This brings us close enough to the desired effect, but if you run the application, you will see that in practice the behavior is still not perfect – the button does not react visually when pressed, which looks just weird.

To fix this, we need to read the property configuration.isPressed inside our custom button style, which tells you whether the button is currently pressed or not. We can use this to improve our style to give some visual indication of whether the button is pressed.

Let’s start simple: we will use Group for the button background, then check configuration.isPressed and return either a flat circle if the button is pressed, or our current darkened circle otherwise:

configuration.label
    .padding(30)
    .background(
        Group {
            if configuration.isPressed {
                Circle()
                    .fill(Color.offWhite)
            } else {
                Circle()
                    .fill(Color.offWhite)
                    .shadow(color: Color.black.opacity(0.2), radius: 10, x: 10, y: 10)
                    .shadow(color: Color.white.opacity(0.7), radius: 10, x: -5, y: -5)
            }
        }
    )

Since able isPressed a circle with a quasi-white color is used, it makes our effect invisible when the button is pressed.

Warning: due to the way that SwiftUI calculates tapable areas, we unintentionally made the click area for our button very small – now you need to tap on the image itself, and not on the unomorphic design around it. Add a modifier to fix this. .contentShape(Circle()) right after .padding(30), forcing SwiftUI to use all available space for tapas.

Now we can create the effect of artificial concavity by flipping the shadow – by copying two modifiers shadow from the base effect by swapping the X and Y values ​​for white and black, as shown here:

if configuration.isPressed {
    Circle()
        .fill(Color.offWhite)
        .shadow(color: Color.black.opacity(0.2), radius: 10, x: -5, y: -5)
        .shadow(color: Color.white.opacity(0.7), radius: 10, x: 10, y: 10)
} else {
    Circle()
        .fill(Color.offWhite)
        .shadow(color: Color.black.opacity(0.2), radius: 10, x: 10, y: 10)
        .shadow(color: Color.white.opacity(0.7), radius: 10, x: -5, y: -5)
}

Estimate the result.

Create internal shadows for a button click

Our current code, in principle, already works, but people interpret the effect differently – some see it as a concave button, others see that the button is still not pressed, just the light comes from a different angle.

The idea of ​​the improvement is to create an inner shadow that will simulate the effect of pressing the button inward. This is not part of the standard SwiftUI kit, but we can implement it quite easily.

Creating an inner shadow requires two linear gradients, and they will be only the first of many internal gradients that we will use in this article, so we will immediately add a small auxiliary extension for LinearGradientto simplify the creation of standard gradients:

extension LinearGradient {
    init(_ colors: Color...) {
        self.init(gradient: Gradient(colors: colors), startPoint: .topLeading, endPoint: .bottomTrailing)
    }
}

With this, we can simply provide a variable list of colors to get back their linear gradient in the diagonal direction.

Now about the important point: instead of adding two flipped shadows to our pressed circle, we are going to overlay a new circle with a blur (stroke), and then apply another circle with a gradient as a mask. This is a little trickier, but let me explain it piecemeal:

  • Our base circle is our current circle with a neomorphic effect, filled with a quasi-white color.
  • We place a circle on top of it, framed by a gray frame, and slightly blur to soften its edges.
  • Then we apply a mask with another circle to this circle superimposed on top, this time filled with a linear gradient.

When you apply one view as a mask for another, SwiftUI uses the alpha channel of the mask to determine what should be displayed in the base view.

So, if we draw a blurry gray stroke, and then mask it with a linear gradient from black to transparent, the blurry stroke will be invisible on one side and gradually increase on the other – we will get a smooth internal gradient. To make the effect more pronounced, we can slightly shift the shaded circles in both directions. Having experimented a bit, I found that drawing a light shadow with a thicker line than a dark one helps maximize the effect.

Remember that two shadows are used to create a sense of depth in neomorphism: one light and one dark, so we will add this effect of the inner shadow twice with different colors.

Change the circle configuration.isPress in the following way:

Circle()
    .fill(Color.offWhite)
    .overlay(
        Circle()
            .stroke(Color.gray, lineWidth: 4)
            .blur(radius: 4)
            .offset(x: 2, y: 2)
            .mask(Circle().fill(LinearGradient(Color.black, Color.clear)))
    )
    .overlay(
        Circle()
            .stroke(Color.white, lineWidth: 8)
            .blur(radius: 4)
            .offset(x: -2, y: -2)
            .mask(Circle().fill(LinearGradient(Color.clear, Color.black)))
    )

If you run the application again, you will see that the effect of pressing a button is much more pronounced and looks better.

On this, the first part of the translation came to an end. In the coming days we will publish the continuation, but now we offer you learn more about the upcoming course.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *