A Pull-Cord Theme Switcher in SwiftUI


Greetings, traveler!

When it comes to great UX in mobile apps, even small details can create moments of delight. In this post, I’ll show you how to build a unique, tactile UI element in SwiftUI — a pull-cord switch, inspired by the kind used to turn lights on and off. It’s a fun and physical-feeling way for users to switch your app’s theme.

What Are We Building?

Instead of a standard toggle, we’ll create a vertical rope hanging from the top of the screen. When the user pulls it down far enough, a callback is triggered — like switching a light on. The interaction feels physical and intuitive, adding personality to your app.

The view includes:

  • A long, thin rope (a vertical rectangle),
  • A downward arrow icon at the bottom,
  • Bouncy animation for a realistic effect,
  • Drag gesture detection with a custom threshold.

Check out this code:

struct PullCordView: View {
    let onPulled: () -> Void
    @State private var dragOffset: CGFloat = .zero
    private let maxPullDistance: CGFloat = 100
    private let maxAngle: Double = 10
    
    var body: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(Color.primary)
                .frame(width: 3, height: 500)
            
            Image(systemName: "arrow.down.circle.fill")
                .resizable()
                .scaledToFill()
                .frame(width: 22, height: 22)
                .padding(.top, -2)
                .foregroundStyle(.primary)
        }
        .animation(.bouncy(extraBounce: 0.28), value: dragOffset)
        .frame(width: 29)
        .contentShape(Rectangle())
        .compositingGroup()
        .gesture(
            DragGesture(minimumDistance: 10)
                .onChanged { value in
                    let delta = value.translation.height
                    guard delta > value.translation.width else { return }
                    
                    if delta > 0 {
                        dragOffset = min(delta, maxPullDistance)
                    }
                }
                .onEnded { value in
                    guard value.translation.height > value.translation.width else { return }
                    if dragOffset > 60 {
                        onPulled()
                    }
                    dragOffset = 0
                }
        )
        .offset(y: dragOffset)
        .padding(.top, -200)
    }
}

How It Works

  • dragOffset tracks how far the rope has been pulled.
  • The DragGesture listens only for vertical pulls, ignoring horizontal ones.
  • If the pull distance exceeds a threshold (60 points), the onPulled callback is fired.
  • The .bouncy animation adds a satisfying elastic feel when the rope is released.

Now, you can use it like this:

struct SettingsView: View {
    private let themeManager = ThemeManager()
    
    var body: some View {
        NavigationStack {
            List {
                themeSwitcherView
            }
            .overlay(alignment: .topTrailing) {
                PullCordView {
                    switchAppearance()
                }
                .ignoresSafeArea()
            }
            .onChange(of: selectedAppearance) { _, _ in
                themeManager.overrideDisplayMode()
            }
        }
    }
}

What about theme switching?

As you can see, I am using a ThemeManager here. You can read more about it here.

Final Thoughts

With just a bit of geometry, gesture recognition, and animation, you can give users a micro-experience that stands out.