Just a few days ago, I received an assignment that requires me to design a controller for a mobile game. In this assignment, I am implementing a knock-off joystick in SwiftUI using simple code.
Enumeration: Funtional Keys and Direction
The rawValue
of these enumeration types is of type String
. Direction defines the four directions: up
, down
, left
, and right
, as well as the four functional keys: A
, B
, C
, and D
.
enum Direction: String{
case up = "Up"
case down = "Down"
case left = "Left"
case right = "Right"
}
enum Function: String{
case a = "A"
case b = "B"
case c = "C"
case d = "D"
}
Size: Joystick Radius
Since the upcoming code will use a container view called Geometry Reader, which can disrupt the original layout, and we want the joystick to maintain a consistent size, we need to explicitly define its size.
let joystickRadius: CGFloat = 80
Here I used 80. This value looks moderately sized on many devices.
However, I acknowledged that traditionally size
s are fixed within modifiers instead of being explicitly declared as constants or variables. Therefore, I will annotate more about this in the upcoming code.
Joystick: Design and Algorithm
struct Joystick: View {
@Binding var direction: Direction?
@State private var currentPosition: CGPoint = .zero
@State private var initialPosition: CGPoint = .zero
@State private var knobPosition: CGPoint = .zero
var body: some View {
GeometryReader { geometry in
ZStack{
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 10)
.frame(width: joystickRadius * 2, height: joystickRadius * 2)
Circle()
.fill(Color.gray.opacity(0.7))
.blur(radius: 1)
.frame(width: 100, height: 100)
.offset(x: knobPosition.x, y: knobPosition.y)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
currentPosition = value.location
if initialPosition == .zero {
initialPosition = currentPosition
}
//calculate offset
let deltaX = currentPosition.x - initialPosition.x
let deltaY = currentPosition.y - initialPosition.y
//calc angle
let theta = atan2(deltaY, deltaX)
//calc offset
var knobOffset: CGPoint
if sqrt(pow(deltaX, 2) + pow(deltaY, 2)) <= joystickRadius{
knobOffset = CGPoint(x: deltaX, y: deltaY)
}else{
knobOffset = CGPoint(x: joystickRadius * cos(theta),
y: joystickRadius * sin(theta))
}
knobPosition = knobOffset
if abs(deltaX) > abs(deltaY) {
direction = deltaX > 0 ? .right : .left
} else {
direction = deltaY > 0 ? .down : .up
}
}
.onEnded { _ in
currentPosition = .zero
initialPosition = .zero
knobPosition = .zero
direction = nil
}
)
}
}
}
}
You may notice that I only use a ZStack
to place the two Circle
s – this is very simple, and in fact, you can customize more elements, such as several small arrows or special images. It is up to you to decide.
Next, we will discuss the algorithm for implementing the characteristic for the center button to move with your finger, but not detach from the outer circular ring.
To calculate and implement the drifting function, we defined three variables wrapped with property wrapper @State
: currentPosition
, initialPosition
, and knobPosition
. They represent the current position of the finger, the initial position of your touch, and the position where the knob should be located, respectively. In the code, currentPosition
and initialPosition
can be obtained in the DragGesture
, while knobPosition
is bound to the knob and is calculated in real-time.
You can easily understand the meanings of deltaX
and deltaY
. Additionally, theta
represents the radian value of the angle formed between the straight line connecting the current position and the initial position and the positive direction of the x-axis which can be worked out by atan2()
.
The following point is quite critical. If the finger’s position falls within the outer circular ring, meaning the distance to the origin is less than the joystickRadius
we defined earlier, which is the radius of the outer circle, we can directly assign deltaX
and deltaY
to knobPosition
. However, if the finger position goes beyond the outer circle, we need to place the knob at the intersection of the straight line formed by currentPosition
and initialPosition
with the circular ring (since we have already calculated theta, it is easy to calculate this coordinate). The scalar of the intersection point can be calculated by multiplying the radius with cos(theta)
and sin(theta)
, respectively.
General: Content View
The following is the main view. In addition to containing the JoystickView
mentioned above, it also adds four FunctionalButton
s and some prompt Text
s, which is required in the assignment. No need to go into much detail about this.
struct JoystickView: View {
@State private var buttonPressed: Function?
@State private var joystickDirection: Direction?
var directionSystemImageName: (Direction?) -> String = { dir in
if let direction = dir{
return "arrow.\(direction.rawValue.lowercased())"
}else{
return "arrow.triangle.2.circlepath"
}
}
var body: some View {
VStack(spacing: 20) {
Text("Button pressed: \(buttonPressed?.rawValue ?? "")")
.font(.title)
Text("Joystick direction: \(joystickDirection?.rawValue ?? "")")
.font(.title)
Image(systemName: directionSystemImageName(joystickDirection))
.resizable()
.scaledToFit()
.frame(width: 50, height: 50, alignment: .center)
HStack{
Joystick(direction: $joystickDirection)
.frame(width: joystickRadius * 2, height: joystickRadius * 2)
Spacer()
VStack{
FuctionalButton(buttonPressed: $buttonPressed, functionKey: .a, color: .blue)
FuctionalButton(buttonPressed: $buttonPressed, functionKey: .c, color: .green)
}
VStack{
FuctionalButton(buttonPressed: $buttonPressed, functionKey: .b, color: .red)
FuctionalButton(buttonPressed: $buttonPressed, functionKey: .d, color: .yellow)
}
}.padding(40)
}
}
}
struct ContentView: View {
var body: some View {
JoystickView()
}
}
Run It!
![](https://blog.onespirit.fyi/wp-content/uploads/2023/03/image-1024x723.png)
![](https://blog.onespirit.fyi/wp-content/uploads/2023/03/image-1-1024x723.png)