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:
- Draggable: The button should be easy to move around the screen.
- Snapping: The button should be able to snap to screen edges and remember its position.
- Responsive: The button should adjust to screen size changes or device rotations, staying pinned to the last edge.
- Interactive: The button should respond to taps, present a context menu, and handle actions such as initiating a recording function and hiding the button until the next session.
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:
- Its current location on the screen (
location). - The edges it has snapped to (
edges).
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:
- 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
}
- 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!