Skip to main content

Command Palette

Search for a command to run...

InteractiveMeshGradient (part 1)

iOS 18.0+ MeshGradient that can be interactively changed

Published
4 min read
InteractiveMeshGradient (part 1)

With iOS 18.0 Apple introduced MeshGradient. It grabbed my attention while watching this year's WWDC What's new in SwiftUI.

With this new SwiftUI View we can create colourful gradients.

MeshGradient(width: 3, height: 3, points: [
    .init(0, 0), .init(0.5, 0), .init(1, 0),
    .init(0, 0.5), .init(0.5, 0.5), .init(1, 0.5),
    .init(0, 1), .init(0.5, 1), .init(1, 1)
], colors: [
    .red, .purple, .indigo,
    .orange, .white, .blue,
    .yellow, .green, .mint
])
.frame(width: 400, height: 400)

If only we could easily control each data point and its colour...

We're going to create our own InteractiveMeshGradient to do exactly that. We're going to use a ViewModel to store our values and we'll pass the values to our MeshGradient.

var mesh: some View {
    MeshGradient(
        width: viewModel.width,
        height: viewModel.height,
        points: viewModel.controls.map { $0.node.position },
        colors: viewModel.controls.map { $0.node.color }
    )
}

Our interactive controls which the user can drag will need a CGPoint location to store where they are positioned compared to our MeshGradient, and they also need a SIMD2 two dimensional vector to store their mathematical position to calculate the mesh. I chose to store both of these values, rather than calculating the point from the location for each body call, because this way we only need to calculate the moving control's new point instead of calculating all points for the render, saving a tiny amount of processing.

final class Node: ObservableObject {
    @Published var color: Color
    @Published var position: SIMD2<Float>

    init(color: Color, position: SIMD2<Float>) {
        self.color = color
        self.position = position
    }
}

final class Control: Identifiable, ObservableObject  {
    let id = UUID()
    let node: Node
    @Published var location: CGPoint

    init(node: Node, location: CGPoint) {
        self.node = node
        self.location = location
    }
}

final class ViewModel: ObservableObject {
    @Published var width: Int
    @Published var height: Int
    @Published var controls: [Control]

    init(width: Int, height: Int, nodes: [Node]) {
        self.width = width
        self.height = height
        self.controls = nodes.map { node in
            // Correct location will be set once the View renders
            Control(node: node, location: .zero)
        }
    }
}

We initialise all locations with CGPoint.zero and we'll need to position them correctly once we have the size of the mesh. We use onGeometryChange to detect when the View has a new size then re-locate all controls. For the interactive controls that will update the points of the mesh in real time, we create a new DragControl: View which has the DragGesture and updates the location and position of the Node when the user starts moving it.

struct DragControl: View {
    @Binding var location: CGPoint
    let node: Node
    let coordinateSpaceSize: CGSize

    var body: some View {
        Circle()
            .fill(node.color)
            .strokeBorder(.white, style: StrokeStyle(lineWidth: 2.0))
            .shadow(color: Color.init(white: 0.3, opacity: 0.5), radius: 8.0)
            .position(location)
            .gesture(onDrag(node: node))
    }

    private func onDrag(node: Node) -> some Gesture {
        DragGesture()
            .onChanged { info in
                // Restricting drag to not go out of the View
                let newLocation = CGPoint(x: min(coordinateSpaceSize.width, max(0, info.location.x)),
                                          y: min(coordinateSpaceSize.height, max(0, info.location.y)))
                node.position = SIMD2(Float(newLocation.x / coordinateSpaceSize.width),
                                      Float(newLocation.y / coordinateSpaceSize.height))
                location = newLocation
            }
    }
}

When we get the new coordinates of the control we'll restrict the location in order to keep the controls inside the View. When the location is updated through the Binding the Control that owns this property will propagate the change through the ViewModel to the InteractiveMeshGradient.

All we need now is to put the pieces together. We create the view for the controls that will inhabit the corners and edges.

var controls: some View {
    GeometryReader { proxy in
        ForEach($viewModel.controls) { $control in
            DragControl(location: $control.location, node: control.node, coordinateSpaceSize: viewSize)
                .frame(width: controlSize.width, height: controlSize.height)
        }
    }
}

We'll put the gradient and the controls on top of each other.

struct InteractiveMeshGradient: View {

    @StateObject var viewModel: ViewModel
    @State private var viewSize: CGSize = .zero

    init(width: Int, height: Int, nodes: () -> [Node]) {
        let nodes = nodes()
        self._viewModel = .init(wrappedValue: ViewModel(
            width: width,
            height: height,
            nodes: nodes
        ))
    }

    var body: some View {
        ZStack {
            Rectangle()
                .strokeBorder(style: StrokeStyle(lineWidth: 1.0, dash: [10.0, 10.0]))
                .opacity(showControls ? 1 : 0)
            mesh
            controls
                .opacity(showControls ? 1 : 0)
        }
        .onGeometryChange(for: CGSize.self) { proxy in
            proxy.size
        } action: { newValue in
            viewSize = newValue
            for control in viewModel.controls {
                control.location = CGPoint(position: control.node.position, size: viewSize)
            }
        }
    }

    var mesh: some View { ... }
    var controls: some View { ... }
}

#Preview {
    InteractiveMeshGradient(width: 3, height: 3) {[
        .init(color: .red, position: .init(0, 0)), .init(color: .purple, position: .init(0.5, 0)), .init(color: .indigo, position: .init(1, 0)),
        .init(color: .orange, position: .init(0, 0.5)), .init(color: .white, position: .init(0.5, 0.5)), .init(color: .blue, position: .init(1, 0.5)),
        .init(color: .yellow, position: .init(0, 1)), .init(color: .green, position: .init(0.5, 1)), .init(color: .mint, position: .init(1, 1))
    ]}
}

Next time we'll add the editing functionality, so we can add new grid points and also change the colours on the nodes. Stay tuned.