← Blog

SwiftUI & Metal: Rotating a Triangle with a Slider

Connect a SwiftUI slider to an Apple Metal renderer: pass the value with @State and @Binding, turn it into a float4x4 rotation matrix, and send it to the vertex shader with setVertexBytes.

In the previous part we built a Metal app with SwiftUI. Our MetalView bridges UIKit and SwiftUI, and all the rendering lives in the MTKView delegate, which creates the costly Metal objects once in its initializer and only the lightweight ones in the draw call. That’s all very nice, but the renderer is static. Let’s make it react to the UI.

UI Interactions with Swift UI and Metal Play video: UI Interactions with Swift UI and Metal

The full Xcode project is on GitHub.

The plan

We need two things:

  1. Send changes in the UI state, like the current slider position, to the renderer. For that we use the @State and @Binding property wrappers.
  2. Send the rotation from the renderer to the shaders on the GPU. That’s where uniforms come in.

A slider sends its value to the renderer through @State and @Binding, and the renderer sends it to the GPU as uniforms

“Uniforms” is a term from OpenGL, where you call functions like glUniform4f to pass small pieces of data to the shaders. Metal has no separate functions for this. We simply use buffers, as you’ll see in a moment.

@State and @Binding

Let’s start with the UI and a state variable for the rotation angle:

@State private var rotation: Float = 0.0

SwiftUI views are very lightweight, and the framework can recreate them at any time for performance reasons. A regular variable could be reset when that happens. With @State, the variable’s lifetime is no longer tied to the view object. SwiftUI keeps it in a separate memory area.

A state variable does more than store data, though. Prefix its name with a dollar sign and you get its projected value, which for @State is a binding. When you change the binding, the state variable changes, and it works the other way too: a change in the state updates the binding. SwiftUI uses this all the time.

Here’s a slider with labels for the rotation range. We pass the binding, $rotation, to the slider, and that’s all it takes to connect the variable and the slider position in both directions:

struct ContentView: View {
    @State private var rotation: Float = 0.0

    var body: some View {
        VStack {
            Spacer()
            MetalView(rotation: $rotation).aspectRatio(1, contentMode: .fit)
            Spacer()

            Text("Rotation")
            HStack {
                Text("-π")
                Slider(value: $rotation, in: -(.pi)...(.pi))
                Text("π")
            }
            Spacer()

            Button("Reset") {
                rotation = 0.0
            }
        }
    }
}

Watch the Reset button. Its callback only sets the property to zero, and because the slider is connected to the same property through a binding, the slider moves back on its own. There’s no need for event handlers or view controllers. It works the other way around too: when you move the slider, the state updates. You can check it by printing the value in an .onChange(of: rotation) modifier.

Passing the angle to the renderer

MetalView gets the same binding, so it’s updated every time the rotation changes:

@Binding var rotation: Float

MetalView bridges SwiftUI and UIKit with UIViewRepresentable. Every time the view needs a refresh, SwiftUI calls updateUIView (or updateNSView on the Mac). Both call one function, and that’s where we hand the current angle to the renderer:

private func updateMetalView() {
    renderer.updateRotation(angle: rotation)
}
#if os(iOS)
extension MetalView: UIViewRepresentable {

    func makeUIView(context: Context) -> some UIView {
        makeMetalView()
    }

    func updateUIView(_ uiView: UIViewType, context: Context) {
        updateMetalView()
    }
}
#elseif os(macOS)
extension MetalView: NSViewRepresentable {

    func makeNSView(context: Context) -> some NSView {
        makeMetalView()
    }

    func updateNSView(_ nsView: NSViewType, context: Context) {
        updateMetalView()
    }
}
#endif

If you first implement updateRotation in the renderer with just a log message, you can see the renderer get the right angle as you drag the slider.

A rotation matrix

Now the angle has to get to the shader. We do it with a matrix. If you don’t know matrices, think of them as a compact and efficient way to do a series of multiplications and additions. Rotating a point around the Z axis changes its X and Y coordinates:

x' = x·cos(angle) − y·sin(angle)
y' = x·sin(angle) + y·cos(angle)
z' = z

Multiplying a 4×4 rotation matrix by the position vector gives you exactly that. We extend the float4x4 type with initializers for rotations around the X, Y and Z axes:

import simd

extension float4x4 {
    init(rotationX angle: Float) {
        self = float4x4(
            [1, 0, 0, 0],
            [0, cos(angle), sin(angle), 0],
            [0, -sin(angle), cos(angle), 0],
            [0, 0, 0, 1]
        )
    }

    init(rotationY angle: Float) {
        self = float4x4(
            [cos(angle), 0, -sin(angle), 0],
            [0, 1, 0, 0],
            [sin(angle), 0, cos(angle), 0],
            [0, 0, 0, 1]
        )
    }

    init(rotationZ angle: Float) {
        self = float4x4(
            [cos(angle), sin(angle), 0, 0],
            [-sin(angle), cos(angle), 0, 0],
            [0, 0, 1, 0],
            [0, 0, 0, 1]
        )
    }
}

Note that each array here is a column, not a row. simd matrices are column-major, so the code looks like the transposed matrix you’d see in a math book.

Sending the matrix to the shader

In the renderer, a private variable holds the current rotation matrix. It starts as the identity matrix, and we rebuild it whenever the slider sends a new angle:

private var rotationMatrix = matrix_identity_float4x4

func updateRotation(angle: Float) {
    rotationMatrix = float4x4(rotationZ: angle)
}

The vertex function gets a new parameter for the matrix and uses it to transform the position:

vertex FragmentInput vertex_main(Vertex v [[stage_in]],
                                 constant float4x4 &matrix [[buffer(1)]]) {
    return {
        .position { matrix * v.position },
        .color { v.color }
    };
}

Back in the renderer’s draw method, one line sends the matrix with setVertexBytes, which creates the Metal buffer for us. The vertices are at index 0, so the matrix goes to index 1:

renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setVertexBytes(&rotationMatrix, length: MemoryLayout<simd_float4x4>.stride, index: 1)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)

And that’s it. The slider now controls the rendering through a SwiftUI binding:

The triangle rotated by the slider in the iPhone preview, with the Reset button below

The corners get cut off because the triangle now reaches outside the view’s coordinate range, from −1 to 1 on each axis.

Written by Luke. I build custom software for businesses, made for the way your team works. Fixed price, delivered in days or weeks. I've been shipping software since 2008, co-founded Codahead, a software house in Kraków, and I've published C++ tutorials on YouTube for six years.

Recent posts

All
Apple Metal on Your Phone: Bridging SwiftUI and MTKView Oct 2026Move Apple Metal code from a Swift playground to a real SwiftUI app for iPhone and Mac: wrap MTKView with UIViewRepresentable and NSViewRepresentable, and render every frame from an MTKViewDelegate. Vertex Descriptors in Apple Metal: Attributes, Offsets and Indexed Drawing Oct 2026Use MTLVertexDescriptor so your Swift and Metal vertex structs don't have to match, calculate attribute offsets the right way with MemoryLayout.offset(of:), and draw a square from four vertices with an index buffer. Your First Triangle in Apple Metal: Shaders and Vertex Buffers Oct 2026Draw a triangle with Apple Metal in a Swift playground: write the vertex and fragment shaders, build the render pipeline, send vertices with setVertexBytes or an MTLBuffer, and get the memory alignment right.