Skip to content

Instantly share code, notes, and snippets.

@joshgalvan
Last active March 17, 2025 00:40
Show Gist options
  • Save joshgalvan/29b7ede649da432a14c50e97e59a2147 to your computer and use it in GitHub Desktop.
Save joshgalvan/29b7ede649da432a14c50e97e59a2147 to your computer and use it in GitHub Desktop.
A virtually perfect recreation of Apple's Voice Memos and Camera record button that can handle resizing from any parent container.
//
// RecordButton.swift
//
// Created by Joshua Galvan on 5/31/23.
//
// Example usage:
//
// RecordButton(isRecording: $isRecording) {
// print("Start")
// } stopAction: {
// print("Stop")
// }
// .frame(width: 70, height: 70)
//
import SwiftUI
struct RecordButton: View {
@Binding var isRecording: Bool
let buttonColor: Color
let borderColor: Color
let animation: Animation
let startAction: () -> Void
let stopAction: () -> Void
init(
isRecording: Binding<Bool>,
buttonColor: Color = .red,
borderColor: Color = .white,
animation: Animation = .easeInOut(duration: 0.25),
startAction: @escaping () -> Void,
stopAction: @escaping () -> Void
) {
self._isRecording = isRecording
self.buttonColor = buttonColor
self.borderColor = borderColor
self.animation = animation
self.startAction = startAction
self.stopAction = stopAction
}
var body: some View {
GeometryReader { geometry in
ZStack {
let minDimension = min(geometry.size.width, geometry.size.height)
Button {
if isRecording {
deactivate()
} else {
activate()
}
} label: {
RecordButtonShape(isRecording: isRecording)
.fill(buttonColor)
}
Circle()
.strokeBorder(lineWidth: minDimension * 0.05)
.foregroundColor(borderColor)
}
}
}
private func activate() {
startAction()
withAnimation(animation) {
isRecording.toggle()
}
}
private func deactivate() {
stopAction()
withAnimation(animation) {
isRecording.toggle()
}
}
}
struct RecordButtonShape: Shape {
var shapeRadius: CGFloat
var distanceFromCardinal: CGFloat
// `b` and `c` come from here: https://spencermortensen.com/articles/bezier-circle/
var b: CGFloat
var c: CGFloat
init(isRecording: Bool) {
self.shapeRadius = isRecording ? 1.0 : 0.0
self.distanceFromCardinal = isRecording ? 1.0 : 0.0
self.b = isRecording ? 0.90 : 0.553
self.c = isRecording ? 1.00 : 0.999
}
var animatableData: AnimatablePair<Double, AnimatablePair<Double, AnimatablePair<Double, Double>>> {
get {
AnimatablePair(Double(shapeRadius),
AnimatablePair(Double(distanceFromCardinal),
AnimatablePair(Double(b), Double(c))))
}
set {
shapeRadius = Double(newValue.first)
distanceFromCardinal = Double(newValue.second.first)
b = Double(newValue.second.second.first)
c = Double(newValue.second.second.second)
}
}
func path(in rect: CGRect) -> Path {
let minDimension = min(rect.maxX, rect.maxY)
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = (minDimension / 2 * 0.82) - (shapeRadius * minDimension * 0.22)
let movementFactor = 0.65
let rightTop = CGPoint(x: center.x + radius, y: center.y - radius * movementFactor * distanceFromCardinal)
let rightBottom = CGPoint(x: center.x + radius, y: center.y + radius * movementFactor * distanceFromCardinal)
let topRight = CGPoint(x: center.x + radius * movementFactor * distanceFromCardinal, y: center.y - radius)
let topLeft = CGPoint(x: center.x - radius * movementFactor * distanceFromCardinal, y: center.y - radius)
let leftTop = CGPoint(x: center.x - radius, y: center.y - radius * movementFactor * distanceFromCardinal)
let leftBottom = CGPoint(x: center.x - radius, y: center.y + radius * movementFactor * distanceFromCardinal)
let bottomRight = CGPoint(x: center.x + radius * movementFactor * distanceFromCardinal, y: center.y + radius)
let bottomLeft = CGPoint(x: center.x - radius * movementFactor * distanceFromCardinal, y: center.y + radius)
let topRightControl1 = CGPoint(x: center.x + radius * c, y: center.y - radius * b)
let topRightControl2 = CGPoint(x: center.x + radius * b, y: center.y - radius * c)
let topLeftControl1 = CGPoint(x: center.x - radius * b, y: center.y - radius * c)
let topLeftControl2 = CGPoint(x: center.x - radius * c, y: center.y - radius * b)
let bottomLeftControl1 = CGPoint(x: center.x - radius * c, y: center.y + radius * b)
let bottomLeftControl2 = CGPoint(x: center.x - radius * b, y: center.y + radius * c)
let bottomRightControl1 = CGPoint(x: center.x + radius * b, y: center.y + radius * c)
let bottomRightControl2 = CGPoint(x: center.x + radius * c, y: center.y + radius * b)
var path = Path()
path.move(to: rightTop)
path.addCurve(to: topRight, control1: topRightControl1, control2: topRightControl2)
path.addLine(to: topLeft)
path.addCurve(to: leftTop, control1: topLeftControl1, control2: topLeftControl2)
path.addLine(to: leftBottom)
path.addCurve(to: bottomLeft, control1: bottomLeftControl1, control2: bottomLeftControl2)
path.addLine(to: bottomRight)
path.addCurve(to: rightBottom, control1: bottomRightControl1, control2: bottomRightControl2)
path.addLine(to: rightTop)
return path
}
}
struct RecordingButton_Previews: PreviewProvider {
static var previews: some View {
RecordButton(isRecording: .constant(true), startAction: {}, stopAction: {})
RecordButton(isRecording: .constant(false), startAction: {}, stopAction: {})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment