Creating Reflections in SwiftUI Canvas

  • May 22

Creating Reflections in SwiftUI Canvas

SwiftUI’s Canvas gives you direct access to a low-level drawing environment while still keeping the declarative nature of SwiftUI. Instead of composing views with stacks and modifiers alone, you can render graphics, gradients, symbols, and transformations manually.

In this tutorial, we will build a glowing reflection effect using Canvas. The final result draws a custom view, flips it vertically, and applies a fading mask to simulate a realistic reflection.

This technique is useful for:

  • Glassmorphism interfaces

  • Media player UIs

  • Arcade-style animations

  • Futuristic dashboards

  • Custom visual effects

Here is the complete implementation:

import SwiftUI

struct ExampleView: View {
    var body: some View {

        Canvas { context, size in
            
            let rect = CGRect(origin: .zero, size: size)
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
                        
            // Add a gradient background
            let lightBlue = Color(.sRGB, red: 0.07, green: 0.56, blue: 0.89, opacity: 1.0)
            let darkBlue = Color(.sRGB, red: 0.03, green: 0.24, blue: 0.38, opacity: 1.0)
            
            context.fill(Path(rect), with: .radialGradient(Gradient(colors: [lightBlue, darkBlue]),
                                                           center: midPoint,
                                                           startRadius: 0,
                                                           endRadius: min(size.width, size.height)))

            // Resolve the view
            if let resolved = context.resolveSymbol(id: 1) {
                // Draw original view
                context.draw(resolved, at: midPoint, anchor: .center)

                let reflectionOrigin = CGPoint(x: midPoint.x, y: midPoint.y - resolved.size.height + 10)
                
                // Invert view
                context.scaleBy(x: 1, y: -1)
                context.translateBy(x: 0, y: -size.height)
                
                // Add cliping with a gradient that will make the reflection to fade
                context.clipToLayer(opacity: 1.0, options: .inverse) { layerContext in
                    var pathOrigin = reflectionOrigin
                    pathOrigin.x -= resolved.size.width
                    pathOrigin.y -= resolved.size.height
                    
                    layerContext.fill(Path(rect),
                                      with: .linearGradient(Gradient(colors: [.black.opacity(0.7), .black]),
                                                            startPoint: CGPoint(x: 0, y: midPoint.y - resolved.size.height / 2.0),
                                                            endPoint: CGPoint(x: 0, y: midPoint.y - (1.5 * resolved.size.height)),
                                                            options: .linearColor))
                }
                
                // Draw the "reflection" view
                context.draw(resolved, at: reflectionOrigin, anchor: .center)
            }
            
        } symbols: {
            MessageView()
                .font(.custom("Chalkduster", size: 30))
                .tag(1)
        }
        .frame(height: 150)
    }
    
    struct MessageView: View {
        @State var isOn = false
        
        var body: some View {
            HStack(spacing: 10) {
                Text("❤️").opacity(isOn ? 1.0 : 0.3)

                Text("DevTechie.com")
                
                Text("❤️").opacity(isOn ? 1.0 : 0.3)
            }
            .onAppear {
                withAnimation(.easeInOut(duration: 0.7).repeatForever()) {
                    isOn.toggle()
                }
            }

        }
    }
}

#Preview {
    ExampleView()
}

Understanding Canvas

Canvas is a drawing surface introduced in SwiftUI that allows you to render graphics using a GraphicsContext.

Canvas { context, size in

The closure provides:

  • context → the drawing engine

  • size → available canvas dimensions

Unlike regular SwiftUI views, Canvas works more like Core Graphics. You manually tell it what to draw and where to draw it.

This gives you fine-grained control over:

  • gradients

  • transforms

  • layers

  • masks

  • symbols

  • custom paths


Creating the Background

The first step is building the glowing radial background.

let rect = CGRect(origin: .zero, size: size)

This creates a rectangle matching the entire canvas size.

Next, we define two custom colors:

let lightBlue = Color(.sRGB, red: 0.07, green: 0.56, blue: 0.89, opacity: 1.0)
let darkBlue = Color(.sRGB, red: 0.03, green: 0.24, blue: 0.38, opacity: 1.0)

Now we fill the canvas using a radial gradient.

context.fill(
    Path(rect),
    with: .radialGradient(
        Gradient(colors: [lightBlue, darkBlue]),
        center: midPoint,
        startRadius: 0,
        endRadius: min(size.width, size.height)
    )
)

The gradient starts at the center and expands outward.

The result creates depth immediately. Instead of a flat background, the canvas now feels illuminated.


Using Symbols in Canvas

One of the most powerful features of Canvas is symbol rendering.

Inside the symbols closure, we define reusable SwiftUI views:

symbols: {
    MessageView()
        .font(.custom("Chalkduster", size: 30))
        .tag(1)
}

A symbol is essentially a SwiftUI view that can later be resolved and drawn inside the canvas.

To access it:

if let resolved = context.resolveSymbol(id: 1)

This converts the SwiftUI view into a drawable object.

Now we can render it anywhere:

context.draw(resolved, at: midPoint, anchor: .center)

This draws the message in the center of the canvas.


Understanding Coordinate Transformations

The reflection effect is created using coordinate transformations.

First we define where the reflection should appear.

let reflectionOrigin = CGPoint(
    x: midPoint.x,
    y: midPoint.y - resolved.size.height + 10
)

Now comes the important part.

context.scaleBy(x: 1, y: -1)

This flips the entire coordinate system vertically.

Anything drawn afterward becomes upside down.

But flipping also moves the coordinate system outside the visible region, so we translate it back.

context.translateBy(x: 0, y: -size.height)

Together these transformations create a mirrored drawing environment.

This is one of the core concepts behind custom graphics programming:

  • scale transforms size/direction

  • translate moves coordinates

  • rotation changes orientation

Instead of modifying views directly, you manipulate the coordinate system itself.


Creating the Reflection Fade

A perfect mirror reflection looks fake.

Real reflections gradually fade out.

To simulate this, we use clipping with a gradient mask.

context.clipToLayer(opacity: 1.0, options: .inverse) { layerContext in

This creates a temporary layer that can be masked independently.

Inside the layer we apply a linear gradient.

layerContext.fill(
    Path(rect),
    with: .linearGradient(
        Gradient(colors: [.black.opacity(0.7), .black]),
        startPoint: CGPoint(
            x: 0,
            y: midPoint.y - resolved.size.height / 2.0
        ),
        endPoint: CGPoint(
            x: 0,
            y: midPoint.y - (1.5 * resolved.size.height)
        ),
        options: .linearColor
    )
)

This gradient controls the visibility of the reflection.

The top remains more visible while the bottom fades away naturally.

The effect feels significantly more realistic than simply lowering opacity.


Drawing the Reflection

Once the coordinate system is flipped and the mask is applied, we draw the symbol again.

context.draw(resolved, at: reflectionOrigin, anchor: .center)

Because the coordinate system is inverted, the symbol appears mirrored.

This is an important concept:
we are not creating a second reversed view manually.

We are drawing the exact same view in a transformed coordinate space.

That makes the reflection:

  • efficient

  • reusable

  • perfectly synchronized

If the original view animates, the reflection updates automatically.


Building the Animated MessageView

The reflected content is a regular SwiftUI view.

struct MessageView: View {

The hearts pulse using state-driven animation.

@State var isOn = false

Opacity changes depending on the state value.

Text("❤️").opacity(isOn ? 1.0 : 0.3)

Then we animate forever:

withAnimation(
    .easeInOut(duration: 0.7)
        .repeatForever()
) {
    isOn.toggle()
}

Because the symbol itself animates, both the original view and its reflection animate together automatically.

This is where Canvas becomes extremely powerful:
you combine declarative SwiftUI views with procedural drawing effects.


Why Canvas Matters

Most SwiftUI developers never move beyond stacks and modifiers.

But Canvas unlocks an entirely different layer of UI development.

You can create:

  • particle systems

  • custom shaders

  • reflections

  • wave animations

  • procedural art

  • advanced masking effects

  • interactive visualizations

Without dropping into UIKit or Core Animation.

For graphics-heavy interfaces, Canvas is one of the most important APIs in SwiftUI.


Final Thoughts

This reflection effect demonstrates several important graphics concepts:

  • symbol rendering

  • gradients

  • coordinate transformations

  • clipping layers

  • masking

  • procedural drawing

The biggest mental shift is understanding that graphics programming is often about transforming the drawing environment rather than transforming individual views.

Once that clicks, effects like reflections, shadows, distortions, and perspective become much easier to build.

Canvas gives SwiftUI developers access to an entirely new category of visual effects while still staying inside SwiftUI’s ecosystem.