BS
BleepingSwift
Published on

> Creating Morphing Glass Transitions with glassEffectID

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

SwiftUI's matchedGeometryEffect has long been the go-to for smooth transitions between views. With Liquid Glass, there's a new tool specifically designed for glass elements: glassEffectID. It enables glass views to fluidly morph into one another, maintaining the translucent material properties throughout the animation.

How It Works

The glassEffectID modifier marks a glass view as participating in morphing transitions. Views with matching identifiers within the same namespace and container will animate smoothly between states.

You need three things to set this up: a @Namespace property to coordinate the animation, a GlassEffectContainer to group the elements, and glassEffectID modifiers on each participating view.

import SwiftUI

struct MorphingExample: View {
    @State private var isExpanded = false
    @Namespace private var animation

    var body: some View {
        GlassEffectContainer(spacing: 20) {
            if isExpanded {
                expandedView
            } else {
                collapsedView
            }
        }
    }

    var collapsedView: some View {
        Button {
            withAnimation(.bouncy) {
                isExpanded = true
            }
        } label: {
            Image(systemName: "plus")
                .font(.title2)
                .foregroundStyle(.white)
                .frame(width: 56, height: 56)
        }
        .glassEffect(.regular, in: .circle)
        .glassEffectID("main", in: animation)
    }

    var expandedView: some View {
        HStack(spacing: 12) {
            Button("Cancel") {
                withAnimation(.bouncy) {
                    isExpanded = false
                }
            }
            .padding()
            .glassEffect()
            .glassEffectID("cancel", in: animation)

            Button("Confirm") { }
                .padding()
                .glassEffect(.regular.tint(.blue))
                .glassEffectID("main", in: animation)
        }
    }
}

When isExpanded toggles, the circular button morphs into the "Confirm" button because they share the "main" identifier. The "Cancel" button appears with its own entrance animation since it has a unique ID.

Choosing Identifiers

The string identifier you pass to glassEffectID determines which views morph into each other. Views with the same ID in the same namespace will transition smoothly; views with different IDs animate independently.

Think carefully about which elements should morph versus which should appear fresh:

@State private var mode: Mode = .browse
@Namespace private var ns

var body: some View {
    GlassEffectContainer(spacing: 24) {
        switch mode {
        case .browse:
            HStack(spacing: 12) {
                Button("Search") { }
                    .padding()
                    .glassEffect()
                    .glassEffectID("search", in: ns)

                Button("Filter") { }
                    .padding()
                    .glassEffect()
                    .glassEffectID("filter", in: ns)
            }

        case .search:
            HStack(spacing: 12) {
                TextField("Search...", text: $query)
                    .padding()
                    .glassEffect()
                    .glassEffectID("search", in: ns)  // Morphs from search button

                Button("Cancel") {
                    withAnimation { mode = .browse }
                }
                .padding()
                .glassEffect()
                .glassEffectID("cancel", in: ns)  // New element
            }
        }
    }
}

The search button and search field share an ID, so tapping search causes the button to expand into the text field. The cancel button has a unique ID, so it animates in separately.

Expandable Menu Example

Here's a more complete example showing a floating action button that expands into a menu:

import SwiftUI

struct ExpandableGlassMenu: View {
    @State private var isOpen = false
    @Namespace private var menuAnimation

    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            LinearGradient(
                colors: [.purple, .blue],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()

            GlassEffectContainer(spacing: 16) {
                VStack(alignment: .trailing, spacing: 12) {
                    if isOpen {
                        menuItem(icon: "camera", label: "Take Photo", id: "camera")
                        menuItem(icon: "photo.on.rectangle", label: "Choose Photo", id: "photo")
                        menuItem(icon: "folder", label: "Browse Files", id: "files")
                    }

                    mainButton
                }
            }
            .padding()
        }
    }

    var mainButton: some View {
        Button {
            withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
                isOpen.toggle()
            }
        } label: {
            Image(systemName: isOpen ? "xmark" : "plus")
                .font(.title2)
                .fontWeight(.semibold)
                .foregroundStyle(.white)
                .frame(width: 56, height: 56)
                .contentTransition(.symbolEffect(.replace))
        }
        .glassEffect(.regular.tint(.indigo), in: .circle)
        .glassEffectID("toggle", in: menuAnimation)
    }

    func menuItem(icon: String, label: String, id: String) -> some View {
        Button {
            // action
        } label: {
            Label(label, systemImage: icon)
                .foregroundStyle(.white)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
        .glassEffect()
        .glassEffectID(id, in: menuAnimation)
    }
}

Each menu item has its own unique ID, so they animate independently when appearing and disappearing. The main toggle button keeps a consistent ID, staying in place while its icon transitions between plus and close.

Morphing Between Different Shapes

Glass elements can morph between different shapes when they share an ID:

@State private var isCircle = true
@Namespace private var shapeNS

var body: some View {
    GlassEffectContainer {
        Button {
            withAnimation(.bouncy) {
                isCircle.toggle()
            }
        } label: {
            Text(isCircle ? "O" : "Expand")
                .foregroundStyle(.white)
                .frame(width: isCircle ? 60 : 120, height: 60)
        }
        .glassEffect(
            .regular,
            in: isCircle ? AnyShape(.circle) : AnyShape(.capsule)
        )
        .glassEffectID("shape", in: shapeNS)
    }
}

The glass smoothly transitions from a circle to a capsule, maintaining its translucent properties throughout the animation.

Requirements and Gotchas

Every view participating in morphing transitions must have glassEffectID applied. If you forget to add it to one of the views, that view won't animate smoothly with the others.

All morphing views need to be inside the same GlassEffectContainer. Views in different containers won't morph together even if they share the same namespace and ID.

The @Namespace property must be the same instance for all related views. If you're building a complex view hierarchy, pass the namespace down or use environment values.

Animation timing matters. Wrap state changes in withAnimation to see the morphing effect. Without an animation block, views will snap between states.

// Good - smooth morph
withAnimation(.bouncy) {
    isExpanded = true
}

// No animation - instant swap
isExpanded = true

Combining with matchedGeometryEffect

You can use glassEffectID and matchedGeometryEffect in the same view hierarchy. Use matchedGeometryEffect for non-glass elements and glassEffectID for glass surfaces. They can share the same namespace:

@Namespace private var animation

// Non-glass element
Text("Title")
    .matchedGeometryEffect(id: "title", in: animation)

// Glass element
Button("Action") { }
    .padding()
    .glassEffect()
    .glassEffectID("action", in: animation)

This gives you consistent, coordinated animations across your entire interface, whether elements use glass styling or not.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.