27. Jun 2022iOS

How to Make a Slide to Unlock Button in SwiftUI

The tutorial doesn’t require adding any third party. The button is built with native SF Symbols and Views. Presented components & modifiers are available for the iOS 13. There is one exception — an improvement mentioned in the 6th step that requires iOS 15. Used colors can be found at the end of the article.

Valeriia AbelovskaiOS Developer

Step 1. Adding a Drag Gesture

We can achieve a "slide" effect by adding a recognition of the drag gesture. The .gesture(DragGesture() modifier attaches a drag gesture to the view . To access the gesture’s values and perform actions let’s use an instance method .onChanged(:). Its closure parameter includes a CGSize from the starting point of the drag gesture to the current position  - value.translation, where width represents a  horizontal axis. Animations below illustrate a size change of the views depending on a dragging distance.

The DraggingComponent containing a gesture modifier is the first step to create an UnlockButton view.


struct DraggingComponent: View {

  let maxWidth: CGFloat
	
  private let minWidth = CGFloat(50)
  @State private var width = CGFloat(50)
	
  var body: some View {
    RoundedRectangle(cornerRadius: 16)
      .fill(Color.blueDark)
      .frame(width: width)
      .gesture(
        DragGesture()
          .onChanged { value in
            if value.translation.width > 0 {
              width = min(max(value.translation.width + minWidth, minWidth), maxWidth)
            }
        }
      )
      .animation(.spring(response: 0.5, dampingFraction: 1, blendDuration: 0), value: width)
  }

}

Step 2. Applying Limits & Visual Details

Additionally to minimum width, we need to limit the maximum size of the DraggingComponent, it can be defined from the parent container. GeometryReader changes the view’s size to the maximum available, this behavior is unnecessary for child views.

struct UnlockButton: View {

    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: .leading) {
                DraggingComponent(maxWidth: geometry.size.width)
            }
        }
        .frame(height: 50)
        .padding()
    }

}

DragGesture has an instance method .onEnded(_:), that adds an action triggred when the gesture ends. Plus let’s add some haptics and an image that layers in front of the current view with trailing alignment.

struct DraggingComponent: View {

  @Binding var isLocked: Bool

  ...

    .frame(width: width)
    .overlay(
      ZStack {
        image(name: "lock", isShown: isLocked)
        image(name: "lock.open", isShown: !isLocked)
      },
      alignment: .trailing
    )
    .gesture(
      DragGesture()
        .onChanged { 
          guard isLocked else { return }
        ...
        }
        .onEnded { value in
          guard isLocked else { return }
          if width < maxWidth {
              width = minWidth
              UINotificationFeedbackGenerator().notificationOccurred(.warning)
          } else {
              UINotificationFeedbackGenerator().notificationOccurred(.success)
              withAnimation(.spring().delay(0.5)) {
                isLocked = false
              }
          }
      }
    )

...

  private func image(name: String, isShown: Bool) -> some View {
    Image(systemName: name)
      .font(.system(size: 20, weight: .regular, design: .rounded))
      .foregroundColor(Color.blueDark)
      .frame(width: 42, height: 42)
      .background(RoundedRectangle(cornerRadius: 14).fill(.white))
      .padding(4)
      .opacity(isShown ? 1 : 0)
      .scaleEffect(isShown ? 1 : 0.01)
  }

}

Step 3. Staging Background

The UnlockButton isn’t completed without a background and a hint note. The BackgroundComponent needs to be placed behind a layer of the DraggingComponent.

struct BackgroundComponent: View {

    var body: some View {
        ZStack(alignment: .leading)  {
            RoundedRectangle(cornerRadius: 16)
                .fill(Color.blueBright.opacity(0.4))

            Text("Slide to unlock")
                .font(.footnote)
                .bold()
                .foregroundColor(.white)
                .frame(maxWidth: .infinity)
        }
    }

}
struct UnlockButton: View {

    @State private var isLocked = true

    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: .leading) {
                BackgroundComponent()
                DraggingComponent(isLocked: $isLocked, maxWidth: geometry.size.width)
            }
        }
        .frame(height: 50)
        .padding()
    }

}

Well done! Unlock button is ready to use 🚀

Golden Touches ✨

Caution! Following steps may cause a creativity burst.

Step 4. Adjusting Color Intensity

SwiftUI has pretty convenient modifiers to work with colors and adapt views to the required design. The hueRotation() modifier allows adjust and animate dominant colors. In color theory Hue can be presented as a circle and angles are used as indicators of color changes.

Let’s add this modifier to the BackgroundComponent.

struct BackgroundComponent: View {

  @State private var hueRotation = false

  var body: some View {
    ZStack(alignment: .leading)  {
    RoundedRectangle(cornerRadius: 16)
      .fill(
        LinearGradient(
          colors: [Color.blueBright.opacity(0.6), Color.blueDark.opacity(0.6)], 
          startPoint: .leading,  
          endPoint: .trailing
        )
      )
      .hueRotation(.degrees(hueRotation ? 20 : -20))

      ...

    }
    .onAppear {
      withAnimation(.linear(duration: 3).repeatForever(autoreverses: true)) {
        hueRotation.toggle()
      }
    }
  }

}

DraggingComponent needs a small adjustment too. Depending on the value of the  drag gesture we can change transparency of the background view, so the background becomes gradually less transparent as closer it gets to the "unlocked" position.

struct DraggingComponent: View {

  ...
      RoundedRectangle(cornerRadius: 16)
        .fill(Color.blueDark)
        .opacity(width / maxWidth)
        .frame(width: width)
  ...

}

Step 5. Supporting Simultaneous Gestures

The current appearance of the UnlockButton view doesn’t have classical button attributes — a tap gesture recognizer and a visual response for the pressed state. The best & native way to access and modify button properties is to use the ButtonStyleConfiguration. To complete this step, we will create a base button style, wrap the DraggingComponent into a Button view and apply the created style to it with the .buttonStyle() modifier, and last but not least support both gestures.

A. Creating a Button Style

struct BaseButtonStyle: ButtonStyle {

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .opacity(configuration.isPressed ? 0.9 : 1)
            .animation(.default, value: configuration.isPressed)
    }

}

B. Wrapping the Component into the Button & Applying the Button Style

struct DraggingComponent: View {

  ...

  var body: some View {
       RoundedRectangle(cornerRadius: 16)
           .fill(Color.blueDark)
           .opacity(width / maxWidth)
           .frame(width: width)
           .overlay(
               Button(action: { }) {
                   ZStack {
                        image(name: "lock", isShown: isLocked)
                        image(name: "lock.open", isShown: !isLocked)
                    }
                }
                .buttonStyle(BaseButtonStyle())
                .disabled(!isLocked),
                alignment: .trailing
          )
  	...
	
	 }

}

C. Supporting Simultaneous Gestures

struct DraggingComponent: View {

  ...

  var body: some View {
    RoundedRectangle(cornerRadius: 16)
      ...
      .overlay( ... )
      .gesture(
      .simultaneousGesture(
        DragGesture()
      )
  ...

}

Step 6. Embracing Asynchrony

In case the unlock action requires a response from the backend, let’s add a loading state. To simplify it, I will simulate the request directly in the view, please stick to MVVM in your project.

struct UnlockButton: View {

  @State private var isLocked = true
  @State private var isLoading = false
  
  var body: some View {
    GeometryReader { geometry in
	    ZStack(alignment: .leading) {
        BackgroundComponent()
        DraggingComponent(isLocked: $isLocked, isLoading: isLoading, maxWidth: geometry.size.width)
      }
    }
    .frame(height: 50)
    .padding()
    .onChange(of: isLocked) { isLocked in
      guard !isLocked else { return }
      simulateRequest()
    }
  }
  private func simulateRequest() {
    isLoading = true

    DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
      isLoading = false
    }
  }
}

The last what left is to add an additional state of the button view in the DraggingComponent.

struct DraggingComponent: View {

  @Binding var isLocked: Bool
  let isLoading: Bool
  let maxWidth: CGFloat

  ...

    .overlay(
      Button(action: { }) {
        ZStack {
          image(name: "lock", isShown: isLocked)
          progressView(isShown: isLoading)
          image(name: "lock.open", isShown: !isLocked && !isLoading)
        }
        .animation(.easeIn(duration: 0.35).delay(0.55), value: !isLocked && !isLoading)
      }
      .buttonStyle(BaseButtonStyle())
      .disabled(!isLocked || isLoading),
      alignment: .trailing
    )

  ...

  private func progressView(isShown: Bool) -> some View {
    ProgressView()
      .progressViewStyle(.circular)
      .tint(.white)
      .opacity(isShown ? 1 : 0)
      .scaleEffect(isShown ? 1 : 0.01)
  }

}

You’ve completed the tutorial. Great job! 🥳

In spite of the fact that the SwiftUI is a young framework with its limitations, it allows you to create beautiful design components pretty fast in so many different ways.

Resources

💡 Source code can be found on GitHub.

extension Color {

    static let pinkBright = Color(red: 247/255, green: 37/255, blue: 133/255)
    static let blueBright = Color(red: 67/255, green: 97/255, blue: 238/255)
    static let blueDark = Color(red: 58/255, green: 12/255, blue: 163/255)

}
Valeriia AbelovskaiOS Developer