[SwiftUI] A floating button that snaps to the edges

In a personal credential managing app I need to create a floating button that can be dragged within a constrained area and once released will snap to the screen edge. Meanwhile, it also has the button’s nature: can recognize tap gesture and perform the action.

The floating button in my App

If you have used “floating window” in the WeChat App, it is the same idea. Another example is the iPhone’s “soft” home button. Some people have to use this AssistiveTouch feature if their physical Home button is broken. It also can be dragged around to rearrange but it will snap to the closest edges once you let it go. And it will expand to a bigger view when you tap on it.


For better demonstration, I will create a simple demo app that only has a floating button.

The core of this effect is to configure multiple gesture recognizers on the button so that it can react to all of them. The snap part is relatively straightforward: You just need to check the position of the button when the drag gesture ends and find out which edge is closest then modify the X/Y position of the button.

In my scenario, I want to make sure the user REALLY wants to rearrange the button, not moving it by accident. I use a long press gesture to bring the button into drag mode. After that, it can be dragged, but within a constraint space. Therefore 3 gestures recognizers will be attached to the button: Long press, Drag, and Tap.

To create the long press gesture recognizer, I need a @State variable called buttonInDrag to track if the button has entered drag mode. It is also used on the button’s modifier to determine the scale factor. The long-press gesture has a minimum duration of 0.3s meaning it only recognizes long-press that is longer than 0.3s as a successful gesture.


@State private var buttonInDrag: Bool = false
...
let hapticImpact = UIImpactFeedbackGenerator(style: .medium)
let longPressGesture = LongPressGesture(minimumDuration: 0.3)
            .onEnded { finished in
                buttonInDrag = true
                hapticImpact.impactOccurred()
            }

To track and update the button’s position, I need to use GeometryReader so I can create the constraint area. The constraint area is on the right side of the screen and has a width of 80 and a height of (the view height - 100).


var body: some View {
    GeometryReader { geometry in
        let SnapTrailing: CGRect = CGRect(x: geometry.size.width - 99, y: 20, width: 100, height: geometry.size.height - 100)

        let hapticImpact = UIImpactFeedbackGenerator(style: .medium)
        let longPressGesture = LongPressGesture(minimumDuration: 0.3)
            .onEnded { finished in
                buttonInDrag = true
                hapticImpact.impactOccurred()
            }
        ...
    } //: End of GeometryReader
} //: End of body

In my drag gesture, I need to update the button’s position to make sure the button cannot be dragged out of the constraint area. To achieve this I need to update the button’s position with gesture’s translation data and check if the new position falls outside of the snapArea. If so, just adjust the x or y of the position to be the x or y of the nearest edges.

Snap Area

The button cannot be moved to the outside of the snap area

When the drag gesture ends, I will set the X value of the position to the right-most of the view but keep the Y value of the position unchanged, so the button will snap to the right edge.

CoordinateSpace is the space where the gesture’s movement is based in. It will map the coordinates into the space that you specified. In this case, the parent view is the coordinate space so I gave the parent view a space name of “MasterHostingView” and specified it in the DragGesture.


let dragGesture = DragGesture(minimumDistance: 0, coordinateSpace: CoordinateSpace.named("TrailingSnapArea"))
            .updating($startPos) { value, gestureStart, transaction in
                gestureStart = gestureStart ?? currPos
            }
            .onChanged { gesture in
                var newLocation = startPos ?? currPos
                newLocation.x += gesture.translation.width
                newLocation.y += gesture.translation.height
                self.currPos = newLocation

                if !SnapTrailing.contains(newLocation) {
                    if newLocation.x <= SnapTrailing.minX || newLocation.x >= SnapTrailing.maxX{
                        self.currPos.x = newLocation.x <= SnapTrailing.minX ? SnapTrailing.minX : SnapTrailing.maxX
                    }
                    if newLocation.y <= SnapTrailing.minY || newLocation.y >= SnapTrailing.maxY {
                        self.currPos.y = newLocation.y <= SnapTrailing.minY ? SnapTrailing.minY : SnapTrailing.maxY
                    }
                }
            }
            .onEnded { value in
                self.currPos.x = SnapTrailing.maxX
                buttonInDrag = false
            }

let longDragGesture = longPressGesture.sequenced(before: dragGesture)

Lastly, I need to combine the long press gesture and the drag gesture to make a new gesture. Because the drag gesture should only be recognized after the long press gesture succeeds, I use the .sequenced(before: ) to combine them together.

The rest is straightforward. Use the .simultaneousGesture() view modifier to attach the gesture to the view.


Button(action:{
        }) {
            Text("Drag me!")
                .font(.title)
        }
        .padding(30)
        .background(buttonState ? Color.green : Color.red)
        .cornerRadius(12)
        .scaleEffect(buttonInDrag ? 1.4 : 1.0)
        .animation(.spring(response: 0.25, dampingFraction: 0.59, blendDuration: 0.0), value: buttonInDrag)
        .position(currPos)
        .simultaneousGesture(longDragGesture)
        .simultaneousGesture(TapGesture() .onEnded{
            self.buttonState.toggle()
        })

Complete code can be found in GitHub. It also contains a view that has 4 snap areas: Leading, Top, Trailing, and Bottom. The button will snap to the corresponding area if released. You can play around with it.

[SwiftUI] Build a password setup view

Every application that requires you to create an account will have this simple page in its registration process, asking for the new password of your account. Depending on the security level of the application, the requirement for your password may be different (8, 9, or more characters long, “at least 1 uppercase/lowercase”, “at least 1 number”, “at least 1 symbol” and etc). I often feel mad after few attempts to type in a password but only found out they do not meet the requirement after I hit “submit” button. 

But I’ve also seen some password setup views that are very straightforward about the password rules and always has visual feedback as I’m typing to tell me whether my password is good or not. Some even indicate if the typed password is strong or weak. That’s a great UX practice to me so I’m trying to build one myself in my app, in SwiftUI.

The requirement for the view is simple: There will be two input fields, one is to type in the password, the other is to confirm the password in case the user had typos in it. The password should only be set if the content in both input fields matches. There should also be some visual indicators to show how the typed password meets the password rules. Preferably, one line of text for each rule, and a checkmark in front of it if that rule has been satisfied.


Let’s get started

I will skip the part that sets up a new SwiftUI project and adding the necessary assets and files, as there are tons of instructions for that already. Let’s say you have a SwiftUI file named PasswordSetupView in your project. Firstly we need to add two text input fields and a button into the view. Let’s use the normal TextField for now. Later I will change it to SecureField and have the “Show Password” button to switch them.

var body: some View {
    VStack(){
        TextField("Enter Password", text: $password)
            .textFieldStyle(PlainTextFieldStyle())
            .padding(5)
            .disableAutocorrection(true)
            .autocapitalization(.none)
        TextField("Re-enter Password", text: $confirmPassword)
            .textFieldStyle(PlainTextFieldStyle())
            .padding(5)
            .disableAutocorrection(true)
            .autocapitalization(.none)
        Button(action:{
            if password.count > 0 && password == confirmPassword {
                savePassword()
            }
        }) {
            HStack(){
                Text("Submit")
                    .font(.title2)
                    .fontWeight(.bold)
                    .foregroundColor(Color.blue)
            }
            .padding(10)
            .background(
                Capsule().strokeBorder(Color.blue, lineWidth: 2.0)
            )
            .accentColor(Color.white)
        }
    }
}

$password and $confirmPassword are two @State variables that binded with the two text input fields. We need to define them in the struct as well

struct PasswordSetupView: View {
    @State var password: String = ""
    @State var confirmPassword: String = ""

    var body: some View {
    ...
    }
}

The logic in the “Submit” button is simple: Save the password only if the password is not empty AND the password is equal to the confirmPassword.

func savePassword() -> Void {
    print("Saved password: \(password)")
    UserDefaults.standard.set(password, forKey: "KMASTER_PASSWORD")
}

Adding the policy-checking capability on the typing

A password policy can consist multiple rules, for example, it has to be “8 or more characters long” and “has at least 1 symbol” and “has at least 1 Uppercase”. We can define our PasswordPolicy as an OptionSet in Swift and each rule is an option in the set. Later we can combine any number of those options to form our password policy.

struct PasswordPolicy: OptionSet {
    let rawValue: Int8

    static let hasUppercase = PasswordPolicy(rawValue: 1<<0)
    static let hasLowercase = PasswordPolicy(rawValue: 1<<1)
    static let hasNumber    = PasswordPolicy(rawValue: 1<<2)
    static let hasSymbol    = PasswordPolicy(rawValue: 1<<3)
}

We define a policy variable to indicate what is the required policy. By default, we should have all of those options. The length of password is tracked separately, using minLength variable. Then we need a @State variable to track how the input password meets the specified policy.

var policy: PasswordPolicy = [.hasUppercase, .hasLowercase, .hasNumber, .hasSymbol]
@State var policyMeet: PasswordPolicy = []
var minLength: Int = 6

The policyMeet variable is used to control the visual feedback on the view as well. If the policy set contains a certain rule, say .hasUppercase, we show a line of text about that rule on the view. Then we keep updating the policyMeet set while user is typing the password. If the policyMeet contains that same rule we change the style of that Text to indicate the rule has been satisfied.

VStack(alignment: .leading, spacing: 2) {
            HStack() {
                Image(systemName: "checkmark.circle")
                    .font(.footnote)
                    .foregroundColor(.green)
                    .opacity(password.count >= minLength ? 1.0 : 0.0)
                Text("Must be \(minLength) characters or longer")
                    .font(.footnote)
                    .foregroundColor(password.count >= minLength ? .green : .red)
                    .opacity(1.0)
            }

            if policy.contains(.hasUppercase) {
                HStack() {
                    Image(systemName: "checkmark.circle")
                        .font(.footnote)
                        .foregroundColor(.green)
                        .opacity(policyMeet.contains(.hasUppercase) ? 1.0 : 0.0)
                    Text("Must contain at least 1 upper case")
                        .font(.footnote)
                        .foregroundColor(policyMeet.contains(.hasUppercase) ? .green : .red)
                }
            }
            ...Similar code for other rules...
}
截屏2021-09-14 下午2.14.20.png

To check if the typed password meets certain rules while the user is typing, we use the .onChange(of:) modifier of the TextField. The onCommit() action of the TextField is not ideal as it only trigged when you commit the change. Firstly we need to define the sets of characters for each rule. Ideally, we should use a Set but since the number of characters is small and limited I just used String for each character set. The checkPassword() is invoked in the .onChange(of:) action of the TextField so it is executed at each keystroke.

let lc = "abcdefghijklmnopqrstuvwxyz"  // for lowercase letters
let uc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"  // for uppercase letters
let nb = "0123456789"  // for numbers
let sb = "~`!@#$%^&*()-_=+{}[]:;<>,.?/"  // for symbols

func checkPassword() -> Void {
    policyMeet = []
    for l in password {
        if lc.contains(l) {policyMeet.insert(.hasLowercase)}
        if uc.contains(l) {policyMeet.insert(.hasUppercase)}
        if nb.contains(l) {policyMeet.insert(.hasNumber)}
        if sb.contains(l) {policyMeet.insert(.hasSymbol)}
    }
}

var body: some View {
    VStack(){
            TextField("Enter Password", text: $password)
                .textFieldStyle(PlainTextFieldStyle())
                .padding(5)
                .disableAutocorrection(true)
                .autocapitalization(.none)
                .onChange(of: password) { newPassword in
                    checkPassword()
                }
            TextField("Re-enter Password", text: $confirmPassword)
                .textFieldStyle(PlainTextFieldStyle())
                .padding(5)
                .disableAutocorrection(true)
                .autocapitalization(.none)
                ...
    }
}
屏幕录制2021-09-14 下午2.16.30.gif

Adding the confirm password indicator

We need one line of text to indicate if the confirm password is same as the password. This can be simply done by adding a HStack with an Image and a Text after the second TextField

HStack() {
            Image(systemName: "checkmark.circle")
                .font(.footnote)
                .foregroundColor(.green)
                .opacity((password.count > 0 && password == confirmPassword) ? 1.0 : 0.0)
            Text("Password match")
                .font(.footnote)
                .foregroundColor((password.count > 0 && password == confirmPassword) ? .green : .red)
                .opacity((password.count > 0 && password == confirmPassword) ? 1.0 : 0.0)
        }

Let’s take a spin of our view

屏幕录制2021-09-14 上午10.47.19.gif

Change the TextField to SecureField

It is more often that the password input field is a secure field. But for the setup view, sometimes it is helpful if the user can see what they typed. We can easily add a button that toggles between TextField and SecureField. The button’s action will toggle a boolean @State variable and in our view build code, we use TextField or SecureField according to that variable’s value. Both TextField and SecureField support all the modifiers we need so it is an easy switch.

But rather than adding the same logic twice, let’s first extract the password input field into a separate view. The first input field need .onChange(of:) modifier but not the second input field. We can make the action an optional function variable in the new view and invoke it in the .onChange(of:) modifier if the action is not nil.

struct PasswordInputField: View {

    var label: String = ""
    @Binding var text: String

    var onChange: ((String) -> Void)? = nil

    var body: some View {

        TextField(label, text: $text)
            .textFieldStyle(PlainTextFieldStyle())
            .padding(5)
            .disableAutocorrection(true)
            .autocapitalization(.none)
            .onChange(of: text) { newText in
                if onChange != nil {onChange!(newText)}
            }

    }
}

Then we changed our callside to use the new PasswordInputField view, one with the onChange function, one without.

PasswordInputField(label: "Enter Password", text: $password, onChange: { _ in
            checkPassword()
        })
...
PasswordInputField(label: "Re-Enter Password", text: $confirmPassword)

Now we can focus on the PasswordInputField. Put an Button with an eye icon into a HStack with the TextField. Add a @State variable called isSecureField, and assign true as the default value. In the action of the Button, toggle that isSecureField. And use it to control which icon to show on the Button as well. Lastly, embed an if-else in the HStack to create TextField or SecureField base on the value of isSecureField.

struct PasswordInputField: View {

    @State private var isSecureField: Bool = true

    var label: String = ""
    @Binding var text: String

    var onChange: ((String) -> Void)? = nil

    var body: some View {
        HStack() {
            if isSecureField {
                SecureField(label, text: $text)
                    .textFieldStyle(PlainTextFieldStyle())
                    .padding(5)
                    .disableAutocorrection(true)
                    .autocapitalization(.none)
                    .onChange(of: text) { newText in
                        if onChange != nil {onChange!(newText)}
                    }
            } else {
                TextField(label, text: $text)
                    .textFieldStyle(PlainTextFieldStyle())
                    .padding(5)
                    .disableAutocorrection(true)
                    .autocapitalization(.none)
                    .onChange(of: text) { newText in
                        if onChange != nil {onChange!(newText)}
                    }
            }

            Button(action: {
                isSecureField.toggle()
            }) {
                Image(systemName: isSecureField ? "eye" : "eye.slash")
                    .font(.title3)
                    .foregroundColor(Color.blue)
            }
        }
    }
}
屏幕录制2021-09-14 下午2.49.46.gif

The final view

Tada! Now we have the fully functional password setup view built in SwiftUI. You can add your tweak in the layout or color or icon to make it looks better. But all the functionalities are there!

屏幕录制2021-09-14 下午3.03.50.gif

The complete code can be found here. Deploy target iOS 14.0, Xcode 12.5.1