Creating Custom Controls in SwiftUI

Goal of this post

Creating a custom control when using UIKit typically involves using a delegate to communicate between the custom control and the code that wants to be informed of state changes in the control. SwiftUI uses a pattern in which the parent has one or more state variables that are passed to the control as a bound variable. Alternately, if the state change is to be communicated more widely, the bound variable may be part of an ObservableObject that is passed along as an environment object. For this example, a state variable passed as a bound variable is used, as well a bound computed variable.

Creating the custom control

This code creates a trackpad control that, when dragged on, sets the values of a bound variable to a CGPoint with an x value and a y value from -1.0 to 1.0.

import SwiftUIstruct TrackpadView: View {  @Binding var location:CGPoint  var body: some View {
GeometryReader { geometry in

Rectangle().gesture(DragGesture().onChanged({ value in
//next 7 lines convert the value passed in by the
//DragGuesture handler to a normalized value between
//-1.0 and 1.0, so it is size independent
let size = geometry.size
let xDistance = value.location.x - (size.width/2.0)
let
yDistance = value.location.y — (size.height/2.0)
let normalizedX = xDistance / (size.width/2.0)
let normalizedY = yDistance / (size.height/2.0)
let clampedX = min(max(normalizedX, -1.0), 1.0) let clampedY = min(max(normalizedY, -1.0), 1.0) //next line applies the new location to the location
//bound variable, no need for a delegate
location = CGPoint(x: clampedX, y: clampedY)
}))
}
}
}
struct TrackpadView_Previews: PreviewProvider {
@State static var location:CGPoint = CGPoint()
static var previews: some View {
TrackpadView(location: $location)
}
}

Note the line

@Binding var location:CGPoint

which is the bound variable passed into the control, and which allows the control to communicate state with the code that created the control. The value is passed back to the parent control in the line

location = CGPoint(x: clampedX, y: clampedY)

no delegate required

Incorporating the custom control into an app

import SwiftUIstruct ContentView: View {  @State var location:CGPoint = CGPoint(x:0, y:0)
@State var opacity:CGFloat = 0
@State var scale:CGFloat = 1.0
var body: some View { let locationConverterProxy = Binding<CGPoint>(
get: {
return CGPoint(x: self.opacity, y:self.scale)
},
set: {
let value = $0
self.opacity = value.x
self.scale = value.y
})
VStack {
Spacer()
HStack {
Spacer()
GeometryReader { geometry in
Image(systemName: "arrow.up.circle").resizable().frame(width: 20.0+(15.0*scale), height: 20.0+(15.0*scale)).opacity(0.6+(Double(opacity)*0.3)).position(convertLocation(geometry.size.width, geometry.size.height))
}
Spacer()
}
Spacer()
HStack {
TrackpadView(location:$location).padding(10).foregroundColor(.gray)
TrackpadView(location:locationConverterProxy).padding(10).foregroundColor(.gray)
}
}
}
func convertLocation(_ width: CGFloat, _ height: CGFloat) -> CGPoint{
return CGPoint(x: (width/2.0 + ((width/2.0)*location.x)), y: (height/2.0 + ((height/2.0)*location.y)))
}
}

The parts of the code that are useful to examine are the seventh through fifteenth lines of code which begin with:

let locationConverterProxy = Binding<CGPoint>(

This permits having a bound variable that converts the CGPoint into two separate CGFloats. In this way the location in the trackpad becomes an opacity and a size in the parent view.

Additionally, the follow code:

TrackpadView(location:$location).padding(10).foregroundColor(.gray)

Is where the trackpad is created and the bound variable is passed, note the “$”.

Logical structure of the example app

The single source of truth in these cases is the variable, either computed or conventional, that is bound to a variable in the control. The use of a computed variable, shown as LocationConverterProxy above, permits decoupling the value returned from the control with the ultimate use that the app has for the value.

The simulator screenshot shows the two trackpads which are created in the above code, which can be used to control the location, size, and opacity of the up arrow graphic.

The code can be easily reused without needing to implement delegates, and the SwiftUI pattern facilitates keeping code in sync, due to maintaining a single source of truth — In this case about the location, size, and opacity of the up arrow graphic.

The code was kept simple to prevent obscuring the key points. In the apps where I use this, I adjust the frame and padding so the trackpads are square and also pass in a background to use for improved aesthetics.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
John Keogh

John Keogh

Founder of EyesBot, mobile, robotics and full stack architect and developer