Skip to content
Go back

Creating a developer tool - part 1

As Swift developers, we often require easily accessible developer tools. Similar to my LifetimeTracker, you might desire a draggable button that can be pinned to screen edges and is responsive to screen changes.

This series of articles aims to guide you through the process of creating a new dev tool and we’ll start with just that: an overlay button that acts as an access point to a tool that we will be developing in the subsequent articles. Our primary target platforms are iOS and macOS.

Objectives & Goals

The core purpose of the overlay button is to serve as an interactive UI, providing developers with a straightforward way to engage with the developer tool. Our requirements for the button are:

Now that we have outlined our objectives, let’s dive into the implementation.

Implementation

We start by importing SwiftUI and Inject. The latter, a handy tool to iterate faster, is a personal preference. Then we define our OverlayButton structure, which conforms to the SwiftUI View protocol.

import SwiftUI
import Inject

struct OverlayButton: View {
  @State
  private var location: CGPoint

  @State
  private var edges = Set<SnapEdge>()

  private let size: CGSize
  private let icon: Image
  private let actionHandler: () -> Void
}

💡

I always use my own Inject to iterate faster.

Button State and Initialization

OverlayButton needs to maintain several states:

The size constant determines the overlay button’s size, and the actionHandler closure is invoked when the button is interacted with.

Crafting the View Body

The body property of OverlayButton consists of a ZStack and GeometryReader. The ZStack allows views to be overlaid, while the GeometryReader gives us access to the parent view’s size. This is useful for adjusting the button’s position in response to screen size changes.

var body: some View {
    ZStack {
        GeometryReader { proxy in
            // Button implementation
        }
    }
    .edgesIgnoringSafeArea(.bottom)
    .enableInjection()
}

Adding Gestures for Dragging

The dragGesture(boundsSize: CGSize) function equips our button with the ability to be dragged. We define a DragGesture and use the .onChanged and .onEnded modifiers to update the button’s location while it’s being dragged and once the drag ends.

private func dragGesture(boundsSize: CGSize) -> some Gesture {
    DragGesture()
        .onChanged { value in
            // Update button's location during dragging
        }
        .onEnded { value in
            // Snap button to the nearest edge when dragging ends
        }
}

During a gesture, DragGesture provides many useful properties, such as translation, location, and predictedEndLocation. They help determine the current and predicted end location of the gesture, giving us control over the button’s behavior during and after the drag.

.onChanged { value in
    var newLocation = startLocation ?? location
    newLocation.x += value.translation.width
    newLocation.y += value.translation.height
    self.location = newLocation
}

This closure is called when the drag gesture ends. It provides the final state of the gesture, using value.predictedEndLocation to guess where the drag would end if it continued at the same velocity. This predicted location aids in calculating the initialVelocity.

.onEnded { value in
    let velocity = CGSize(
        width:  value.predictedEndLocation.x - value.location.x,
        height: value.predictedEndLocation.y - value.location.y
    )
    let initialVelocity = min(20, sqrt(velocity.width * velocity.width + velocity.height * velocity.height))

    withAnimation(
        .interpolatingSpring(
            initialVelocity: initialVelocity
        )) {
        self.location = snap(location, boundsSize: boundsSize)
    }
}

The last piece of the puzzle is making sure we have proper offset when initially dragging the toggle, for this we can use updating modifier to make sure our calculation in the onChanged takes into account the starting finger position in relation to the toggle itself:

.updating($startLocation) { (value, startLocation, transaction) in
  startLocation = startLocation ?? location
}

In essence, dragGesture(boundsSize: CGSize) is the function that makes the button draggable. It ensures a smooth drag movement and a nice settling against the screen edges when the dragging ends.

Snapping Function for Edge Detection

We have two versions of the snap function:

  1. The snap(_ position: CGPoint, boundsSize: CGSize) function calculates the new position of the button during the drag and updates the set of edges the button is currently snapped to.
private func snap(_ position: CGPoint, boundsSize: CGSize) -> CGPoint {
    var position = position
    let frame = CGRect(origin: position, size: size)

    edges.removeAll()

    // Snap to the left or right edge
    if frame.minX <= size.width {
        position.x = 0
        edges.insert(.left)
    } else if frame.maxX >= boundsSize.width - size.width * 0.5 {
        position.x = boundsSize.width
        edges.insert(.right)
    }

    // Snap to the top or bottom edge
    if frame.minY <= size.height {
        position.y = 0
        edges.insert(.top)
    } else if frame.maxY >= boundsSize.height - size.height * 0.5 {
        position.y = boundsSize.height
        edges.insert(.bottom)
    }

    return position
}
  1. The snap(newBounds: CGSize) function updates the button’s position when the screen size changes, ensuring the button stays pinned to its last edge.
private func snap(newBounds: CGSize) {
    if edges.contains(.left) {
        location.x = 0
    } else if edges.contains(.right) {
        location.x = newBounds.width
    }

    if edges.contains(.top) {
        location.y = 0
    } else if edges.contains(.bottom) {
        location.y = newBounds.height
    }
}

Initial integration

Now that we have the button we need a way to integrate it as part of our SwiftUI application, the easiest way is to create a top level model to track the state and generate Views based off it:

class DevToolModel: ObservableObject {
    @Published
    var isVisible: Bool

    @ViewBuilder
    var overlay: some View {
        OverlayButton(size: .init(width: 40, height: 40), icon: .init(systemName: "magnifyingglass")) {
            withAnimation {
                self.isVisible = true
            }
        }
    }

    @ViewBuilder
    var devTool: some View {
        if isVisible {
            Rectangle()
                .fill(Color.blue)
                .transition(.move(edge: .bottom))
        }
    }
}

and integrate as follows at the root ContentView of your app:

struct ContentView: View {
    @StateObject var devTool = DevToolModel(isVisible: false)

    var body: some View {
        ZStack {
            VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text("Hello, world!")
            }
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)

            devTool.overlay
            devTool.devTool
        }
    }
}

This creates the following:

Example integration

Conclusion

We’ve now built a dynamic and interactive overlay button using SwiftUI that can be dragged, snaps to screen edges, and remains responsive to screen changes. This handy floating button will serve as the stepping-stone to our next development phase – creating the actual developer tool.

In the next part of this series, we’ll be focusing on building the developer tool. Stay tuned!


Share this post on:

Previous Post
Creating Developer Tool - Part 2
Next Post
The Journey to Financial Independence as a Programmer