Use matchedGeometryEffect to create ‘slide’ effect for border

I’m trying to create a custom ‘tab’ selection control with a horizontal row of options and the user can select one of N number of options. The ‘selected’ option will have a ‘border’ around it. Here’s a prototype I made:

@objc public enum ContactTabStyle: Int, CaseIterable {
    case one, two, three, four
    
    public var segmentTitle: String {
        switch self {
            case .one: return "Hello"
            case .two: return "World"
            case .three: return "Three"
            case .four: return "Four"
        }
    }
}

struct SwiftUIView: View {
    let segments: [ContactTabStyle] = [.one, .two, .three, .four]
    @State var selectedTab: ContactTabStyle = .one
    
    @Namespace var tabName
    
    var body: some View {
        HStack {
            ForEach(segments, id: \.self) { segment in
                Button {
                    selectedTab = segment
                } label: {
                    Text(segment.segmentTitle)
                        .padding(12.0)
                        .border(selectedTab == segment ? Color.blue : Color.clear, width: 3.0)
                        .cornerRadius(4.0)
                        .matchedGeometryEffect(id: segment.segmentTitle, in: tabName) // doesn't work
                }
            }
        }
    }
}

The view looks and works fine, but I can’t get the animation to ‘slide’ from one selection to another. It just does a normal SwiftUI fade-in-and-out. I believe I should use matchedGeometryEffect to get the sliding effect, but it doesn’t seem to be working. I’ve tried adding the matchedGeometryEffect to the label around the button as well, but it didn’t work either.

Here’s a preview of what it looks like:

enter image description here

The Texts don’t need to match geometry – it’s the borders that need to match geometry.

If you use border to make a border, the border is not its own “view”, so you can’t modify only the border with matchedGeometryEffect. One workaround is to add the border as the background of either the Text or the Button (these produce slightly different effects – see which you like better).

Button {
    selectedTab = segment
} label: {
    Text(segment.segmentTitle)
        .padding(12.0)
        .background {
            if selectedTab == segment {
                RoundedRectangle(cornerRadius: 4)
                    .stroke(lineWidth: 3)
                    // every border should have the same id!
                    .matchedGeometryEffect(id: "selection", in: tabName)
            }
        }
        
}

or

Button {
    selectedTab = segment
} label: {
    Text(segment.segmentTitle)
        .padding(12.0)
        
}
.background {
    if selectedTab == segment {
        RoundedRectangle(cornerRadius: 4)
            .stroke(Color.accentColor, style: .init(lineWidth: 3))
            .matchedGeometryEffect(id: "selection", in: tabName)
    }
}
.animation(.default, value: selectedTab)

Leave a Comment