MTLDoc

SwiftUI Gradient Builder

Gradient Builder

In the last post about gradient shader I covered GPU part of gradient rendering in Metal but if you’ll use it in your apps it won’t make enough sense without giving user the ability to build a gradient in UI. I’m a big fan of SwiftUI and in my opinion current state of this new UI framework including iOS16 API gives all the necessary tools to build apps of any kinds and complexity. Today I’ll show you how to create this fancy control to construct gradients purely on SwiftUI.

TL; DR

I know that a lot of you folks prefer Swift over English ;) I’m also this kind of guy so in order not to torture you with a lot of text here is a repo with all the code from this post and you can go through the implementation by yourself. The repo also contains the CPU part of gradient rendering which I skipped in previous post.

Decomposing Control

Gradient Builder

For our gradient control there will be four separate views packed in a single root view.

Why view decomposition in SwiftUI matters? It is not only about readability and reusability of your code. SwiftUI view lifecycle has two main stages. Like any Swift struct it has init which will be called each time you create a view in other view’s or scene’s body and it has this body which will be fired each time view’s state changes. So your main goal as a developer is to limit unnecessary body calls when view state doesn’t actually changed but SwiftUI thinks it does. How it can happen?

Actually any change in @State or @Binding in view or @Published variable of ObservableObject will require body call on next frame. That means that if you have a huge view described in a single view’s body in all this fine declarative way the whole body will be called each time you change the title of one tiny button. So proper decomposition not only allows you to maintain code easily but makes it faster!

The last thing before diving into views’ implementation we need to define the data model.

Gradient Stop Model

We won’t use SwiftUI Gradient.Stop as data model for two reasons. First since this control is made for gradient shader from previous post we need color to be defined as SIMD4<Float> but not SwiftUI Color. Second we need to support selection of particular stop to allow user to change the color. And to preserve selection while changing color or location we need our model to conform to Identifiable protocol and has some static unique ID.

struct GradientStop: Identifiable {
    let id: UUID
    var location: Float
    var color: SIMD4<Float>

    init(location: Float, color: SIMD4<Float>) {
        self.id = UUID()
        self.location = location
        self.color = color
    }
}

⚠️ The full Gradient model contains gradient type and rotation angle but I omit them in this post. Also we need to implement conversion between SIMD color and SwiftUI color which I also won’t show here to keep focus on UI related code. You can find the full implementation in repo.

GradientView

This view is pretty straightforward. It just renders linear gradient inside RoundedRectangle with semitransparent border overlay. And on tap gesture the new stop will be added to stops array between last two elements. It is the only problem with current implementation since TapGesture in SwiftUI doesn’t provide the location of tap. In iOS16 there is a new SpatialTapGesture added which provides the location but I wanted to keep iOS15 as deployment target so that you’ll be able to run the code from repo on your current devices.

struct GradientView: View {
    init(stops: Binding<[Gradient.GradientStop]>) {
        self._stops = stops
    }

    var body: some View {
        RoundedRectangle(cornerRadius: 8, style: .continuous)
            .fill(
                LinearGradient(
                    stops: self.stops.map(\.ui),
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
            .overlay(
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .stroke(lineWidth: 2)
                    .foregroundColor(.white.opacity(0.7))
            )
            .frame(height: 32)
            .onTapGesture {
                let stops = self.stops.suffix(2)
                let location = stops.reduce(0) { $0 + $1.location } / 2.0
                let color = stops.reduce(.zero) { $0 + $1.color } / 2.0

                self.stops.insert(
                    Gradient.GradientStop(
                        location: location,
                        color: color
                    ),
                    at: self.stops.count - 1
                )
            }
    }

    @Binding private var stops: [Gradient.GradientStop]
}

StopsView

Above gradient view there is a container view for all the stops. It is also rather easy to implement. Just ForEach loop on all the stops. But since our model define stop location in normalized coordinates between 0 and 1 we need to provide the parent view width to this view to place stops in proper locations. Also this view receives binding to selected stop ID and proxies it to all the StopViews to support selection.

struct StopsView: View {
    init(
        width: CGFloat,
        stops: Binding<[Gradient.GradientStop]>,
        selectedStopID: Binding<UUID?>
    ) {
        self.width = width
        self._stops = stops
        self._selectedStopID = selectedStopID
    }

    var body: some View {
        ForEach(self.$stops) { stop in
            StopView(
                stops: self.$stops,
                stop: stop,
                selectedStopID: self.$selectedStopID,
                width: self.width
            )
        }
    }

    private let width: CGFloat
    @Binding private var stops: [Gradient.GradientStop]
    @Binding private var selectedStopID: UUID?
}

This is another reason why we need to conform GradientStop to Identifiable protocol. ForEach supports iteration on array binding and inside its content stop will also be a binding. But to be able to call this initializer your data for iteration needs to be Identifiable.

StopView

This view is just a circle with current stop color, border and shadow. But it supports stop dragging and deletion. To place the stop on proper position it also receives the same width as StopsView and to support stop removing and prevent user from deleting the last two stops we need access to full stops array. Also we need full array to sort it during drag gesture because SwiftUI requires stops to be properly ordered based on location while rendering GradientView.

struct StopView: View {
    init(
        stops: Binding<[Gradient.GradientStop]>,
        stop: Binding<Gradient.GradientStop>,
        selectedStopID: Binding<UUID?>,
        width: CGFloat
    ) {
        self._stops = stops
        self._stop = stop
        self._selectedStopID = selectedStopID
        self.width = width
    }

    var body: some View {
        Circle()
            .foregroundColor(Color(self.stop.color.cg))
            .frame(width: 24, height: 24)
            .overlay(
                Circle()
                    .stroke(lineWidth: 2)
                    .foregroundColor(self.selectedStopID == self.stop.id ? .accentColor : .white.opacity(0.7))
            )
            .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
            .position(x: self.width * CGFloat(self.stop.location))
            .gesture(self.dragGesture)
            .contextMenu {
                if self.stops.count > 2 {
                    Button("Delete") {
                        self.stops.removeAll { $0.id == self.stop.id }
                    }
                } else {
                    EmptyView()
                }
            }
            .onTapGesture {
                self.selectedStopID = self.stop.id
            }
    }

    private let width: CGFloat

    @Binding private var stops: [Gradient.GradientStop]
    @Binding private var stop: Gradient.GradientStop
    @Binding private var selectedStopID: UUID?
    @GestureState private var initialLocation: Float?

    private var dragGesture: some Gesture {
        DragGesture()
            .updating(self.$initialLocation) { _, state, _ in
                guard state == nil else { return }
                state = self.stop.location
            }
            .onChanged { value in
                let location = (self.initialLocation ?? 0) + Float(value.translation.width / self.width)
                self.stop.location = location.clamped(to: 0 ... 1)
                self.stops.sort { $0.location < $1.location }
            }
    }
}

There is a nice hint which I found while implementing this view. You may already know that order of view modifiers matters in SwiftUI because every modifier returns some kind of View and the next one receives already modified view. If you’re not familiar with this behavior try to make Rectangle in SwiftUI and add .foregroundColor and .padding in different order you’ll get different results. And for me it was clear with UI related modifiers. But I was surprised when I got infinite loop, 100% CPU load and stuck UI during drag gesture when .gesture modifier was placed before .position one. I’m still not sure why this happened but since view’s body is called every frame while dragging there was some kind of position conflict I guess. Gesture’s .onChanged action was infinitely called as well as body after it. Maybe it was because gesture updates position but the actual update comes only on the next frame and .position modifier places the view on previous position. So the resulting view doesn’t receive any position update and gesture tries to update it again and again. If you can explain this strange behavior from engineering perspective but not from magical one please add it to comments I would really appreciate it :)

In this view I also used @GestureState to store initial location of the stop when drag begins. This wrapper is very useful when you work with gestures and need to store some transient variable during gesture recognition. You need to implement .updating method for each @GestureState. Its closure receives current state of the whole gesture with initial location, translation and so on, current state of @GestureState variable as inout parameter and animation transaction. So you update this inout parameter inside closure and it is stored in state variable. Pretty weird API as for me but it works. Another advantage of @GestureState is that it automatically reset to initial value when gesture ends.

And on tap gesture we simply assign current stop’s ID to selectedStopID variable. Pretty easy. The final view before combine them all together is the view which shows selected stop color value and allows to change it.

SelectedStopView

struct SelectedStopView: View {
    init(
        stops: Binding<[Gradient.GradientStop]>,
        stopID: Binding<UUID?>
    ) {
        self._stops = stops
        self._stopID = stopID
    }

    var body: some View {
        HStack {
            ForEach(self.channels, id: \.title) { channel in
                Text("\(channel.title): \(channel.value)")
            }

            ColorPicker(
                selection: self.color,
                label: EmptyView.init
            )
            .disabled(self.stopID == nil)
        }
        .font(.system(size: 12))
        .foregroundColor(self.stopID == nil ? .gray : .black)
    }

    @State private var placeholder: Color = .clear
    @Binding private var stops: [Gradient.GradientStop]
    @Binding private var stopID: UUID?

    private var stop: Binding<Gradient.GradientStop>? {
        self.$stops.first { $0.id == self.stopID }
    }

    private var color: Binding<Color> {
        if let stop = self.stop {
            return stop.color.ui
        } else {
            return self.$placeholder
        }
    }

    private var channels: [(title: String, value: String)] {
        func channelValue(_ value: Float?) -> String {
            value.map { String(describing: Int($0 * 255)) } ?? "-"
        }

        let color = self.stop?.wrappedValue.color

        return [
            ("R", channelValue(color?[0])),
            ("G", channelValue(color?[1])),
            ("B", channelValue(color?[2])),
            ("A", channelValue(color?[3])),
        ]
    }
}

I think everything’s clear here. We just show integer value for each color channel and SwiftUI ColorPicker. Since color picker doesn’t allow optional color we need placeholder binding with clear color for the case when no stop is selected. Let’s combine all these four views into a single one root view.

GradientBuilderView

A warm welcome to GeometryReader! I’m not a big fan of it but sometimes we need plain old frames like in ancient UIKit times :) Here we need to know the view width to place stops properly inside it. If you’ll have fixed width for this builder you don’t need to wrap it in GeometryReader of course.

struct GradientBuilderView: View {
    init(gradient: Binding<Gradient>) {
        self._gradient = gradient
    }

    var body: some View {
        VStack {
            GeometryReader { proxy in
                GradientView(stops: self.$gradient.stops)
                    .frame(width: proxy.size.width, height: 32)

                StopsView(
                    width: proxy.size.width,
                    stops: self.$gradient.stops,
                    selectedStopID: self.$selectedStopID
                )
                .frame(width: proxy.size.width, height: 24)
                .offset(y: 16)
            }
            .frame(height: 32)

            SelectedStopView(
                stops: self.$gradient.stops,
                stopID: self.$selectedStopID
            )
        }
    }

    @State private var selectedStopID: UUID?
    @Binding private var gradient: Gradient
}

This view also contains segmented control for gradient type selection and rotation angle slider but since they are default SwiftUI views there is no need to show them here.

And that’s it! The whole view with all the logic and UI took about 200 lines of code! The full implementation of this control and gradient shader you can find on GitHub particularly here. Thank you for reading! If you have any questions feel free to write me an email to contact@mtldoc.com or connect via Twitter @petertretyakov. Next time there will a post about Metal, I promise ;)