Jul 18, 2024
Jul 18, 2024
Jul 18, 2024
5 mins
5 mins
5 mins
SwiftUI
SwiftUI
SwiftUI
Holding Button Example in SwiftUI
Holding Button Example in SwiftUI
Holding Button Example in SwiftUI
Last week, I’ve been thinking a lot about optimizing developers’ time and finding ways to eliminate the need to create common components from scratch for every project.
One of the first components I tackled was a holding button with three predefined states: .cancel
, .delete
, and .confirm.
This component not only saves time by simplifying the creation process but also helps avoid the need for additional confirmation popups, consolidating everything into one efficient button.
Here’s the code for the button. Feel free to use it in your projects:
struct HoldButton: View {
let holdButton: HoldButtonType
let onConfirm: () -> Void
@GestureState private var isPressing = false
@State private var backgroundColor: Color
@State private var progress: CGFloat = 0.0
@State private var scaleEffect: CGFloat = 1.0
@State private var progressTimer: Timer?
init(holdButton: HoldButtonType, onConfirm: @escaping () -> Void) {
self.holdButton = holdButton
self.onConfirm = onConfirm
self._backgroundColor = State(initialValue: holdButton.data.initialStyle)
}
var body: some View {
Label(title(),systemImage: icon())
.foregroundColor(progress == 1.0 ? holdButton.data.initialStyle : .white)
.fontWeight(.semibold)
.symbolEffect(.bounce, options: .speed(animationSpeed()).repeat(repeatCount()), value: isPressing)
.frame(maxWidth: .infinity)
.frame(height:52)
.background(backgroundColor)
.cornerRadius(12)
.overlay(progressOverlay)
.padding(.horizontal, 20)
.gesture(longPressGesture)
.onChange(of: isPressing) { handlePressChange() }
.scaleEffect(scaleEffect)
.animation(.easeInOut(duration: 0.5), value: scaleEffect)
}
private var progressOverlay: some View {
RoundedRectangle(cornerRadius: 12.0)
.trim(from: 0, to: progress)
.stroke(holdButton.progressGradient, lineWidth: progress == 1.0 ? 0 : 2)
.shadow(color: holdButton.data.progressGradientColors.first!, radius: 10, x: 0, y: 0)
.animation(.easeInOut, value: progress)
}
private var longPressGesture: some Gesture {
LongPressGesture(minimumDuration: 3.0)
.updating($isPressing) { currentState, gestureState, _ in
gestureState = currentState
}
}
private func title() -> String {
if progress == 1.0 {
return holdButton.data.finalTitle
} else if isPressing {
return "Keep holding..."
} else {
return holdButton.data.initialTitle
}
}
private func icon() -> String {
if progress == 1.0 {
return "checkmark"
} else if isPressing {
return "hand.tap.fill"
} else {
return "hand.tap"
}
}
private func animationSpeed() -> Double {
if progress == 1.0 {
return 0.2
} else if isPressing {
return 0.3
} else {
return 0
}
}
private func repeatCount() -> Int {
if isPressing {
return 2
} else {
return 0
}
}
private func handlePressChange() {
if isPressing {
moveProgress()
} else if progress < 1.0 {
resetProgress()
}
}
private func moveProgress() {
invalidateTimer()
progress = 0.0
scaleEffect = 1.0
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
if isPressing && progress < 1.0 {
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 0.95
progress += 0.01
updateBackgroundColor()
}
}
} else {
progress = min(progress, 1.0)
progressTimer?.invalidate()
if progress >= 1.0 {
performCompletionAnimation()
}
}
}
}
private func performCompletionAnimation() {
onConfirm()
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 1.1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.easeInOut) {
scaleEffect = 1.0
}
}
}
}
private func resetProgress() {
invalidateTimer()
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 1.0
progress = 0.0
updateBackgroundColor()
}
}
}
private func invalidateTimer() {
progressTimer?.invalidate()
}
private func updateBackgroundColor() {
let targetOpacity: Double = {
if progress == 1.0 {
return 0.2
} else if isPressing {
return 0.1
} else {
return 1.0
}
}()
withAnimation(.linear(duration: progress == 1.0 ? 1.0 : 2.0)) {
backgroundColor = holdButton.data.initialStyle.opacity(targetOpacity)
}
}
enum HoldButtonType {
case cancel
case confirm
case delete
var data: (initialTitle: String, finalTitle: String, initialStyle: Color, progressGradientColors: [Color]) {
switch self {
case .cancel:
return ("Hold to cancel", "Nice, it's canceled!", .orange, [
Color(red: 255 / 255, green: 197 / 255, blue: 61 / 255),
Color(red: 255 / 255, green: 94 / 255, blue: 7 / 255)
])
case .confirm:
return ("Hold to confirm", "Nice, it's confirmed!", .green, [
Color(red: 0 / 255, green: 204 / 255, blue: 130 / 255),
Color(red: 56 / 255, green: 178 / 255, blue: 46 / 255)
])
case .delete:
return ("Hold to delete", "Nice, it's deleted!", .red, [
Color(red: 161 / 255, green: 0 / 255, blue: 63 / 255),
Color(red: 230 / 255, green: 61 / 255, blue: 66 / 255),
Color(red: 255 / 255, green: 127 / 255, blue: 79 / 255)
])
}
}
var progressGradient: LinearGradient {
LinearGradient(
gradient: Gradient(colors: data.progressGradientColors),
startPoint: .trailing,
endPoint: .leading
)
}
}
}
Last week, I’ve been thinking a lot about optimizing developers’ time and finding ways to eliminate the need to create common components from scratch for every project.
One of the first components I tackled was a holding button with three predefined states: .cancel
, .delete
, and .confirm.
This component not only saves time by simplifying the creation process but also helps avoid the need for additional confirmation popups, consolidating everything into one efficient button.
Here’s the code for the button. Feel free to use it in your projects:
struct HoldButton: View {
let holdButton: HoldButtonType
let onConfirm: () -> Void
@GestureState private var isPressing = false
@State private var backgroundColor: Color
@State private var progress: CGFloat = 0.0
@State private var scaleEffect: CGFloat = 1.0
@State private var progressTimer: Timer?
init(holdButton: HoldButtonType, onConfirm: @escaping () -> Void) {
self.holdButton = holdButton
self.onConfirm = onConfirm
self._backgroundColor = State(initialValue: holdButton.data.initialStyle)
}
var body: some View {
Label(title(),systemImage: icon())
.foregroundColor(progress == 1.0 ? holdButton.data.initialStyle : .white)
.fontWeight(.semibold)
.symbolEffect(.bounce, options: .speed(animationSpeed()).repeat(repeatCount()), value: isPressing)
.frame(maxWidth: .infinity)
.frame(height:52)
.background(backgroundColor)
.cornerRadius(12)
.overlay(progressOverlay)
.padding(.horizontal, 20)
.gesture(longPressGesture)
.onChange(of: isPressing) { handlePressChange() }
.scaleEffect(scaleEffect)
.animation(.easeInOut(duration: 0.5), value: scaleEffect)
}
private var progressOverlay: some View {
RoundedRectangle(cornerRadius: 12.0)
.trim(from: 0, to: progress)
.stroke(holdButton.progressGradient, lineWidth: progress == 1.0 ? 0 : 2)
.shadow(color: holdButton.data.progressGradientColors.first!, radius: 10, x: 0, y: 0)
.animation(.easeInOut, value: progress)
}
private var longPressGesture: some Gesture {
LongPressGesture(minimumDuration: 3.0)
.updating($isPressing) { currentState, gestureState, _ in
gestureState = currentState
}
}
private func title() -> String {
if progress == 1.0 {
return holdButton.data.finalTitle
} else if isPressing {
return "Keep holding..."
} else {
return holdButton.data.initialTitle
}
}
private func icon() -> String {
if progress == 1.0 {
return "checkmark"
} else if isPressing {
return "hand.tap.fill"
} else {
return "hand.tap"
}
}
private func animationSpeed() -> Double {
if progress == 1.0 {
return 0.2
} else if isPressing {
return 0.3
} else {
return 0
}
}
private func repeatCount() -> Int {
if isPressing {
return 2
} else {
return 0
}
}
private func handlePressChange() {
if isPressing {
moveProgress()
} else if progress < 1.0 {
resetProgress()
}
}
private func moveProgress() {
invalidateTimer()
progress = 0.0
scaleEffect = 1.0
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
if isPressing && progress < 1.0 {
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 0.95
progress += 0.01
updateBackgroundColor()
}
}
} else {
progress = min(progress, 1.0)
progressTimer?.invalidate()
if progress >= 1.0 {
performCompletionAnimation()
}
}
}
}
private func performCompletionAnimation() {
onConfirm()
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 1.1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.easeInOut) {
scaleEffect = 1.0
}
}
}
}
private func resetProgress() {
invalidateTimer()
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 1.0
progress = 0.0
updateBackgroundColor()
}
}
}
private func invalidateTimer() {
progressTimer?.invalidate()
}
private func updateBackgroundColor() {
let targetOpacity: Double = {
if progress == 1.0 {
return 0.2
} else if isPressing {
return 0.1
} else {
return 1.0
}
}()
withAnimation(.linear(duration: progress == 1.0 ? 1.0 : 2.0)) {
backgroundColor = holdButton.data.initialStyle.opacity(targetOpacity)
}
}
enum HoldButtonType {
case cancel
case confirm
case delete
var data: (initialTitle: String, finalTitle: String, initialStyle: Color, progressGradientColors: [Color]) {
switch self {
case .cancel:
return ("Hold to cancel", "Nice, it's canceled!", .orange, [
Color(red: 255 / 255, green: 197 / 255, blue: 61 / 255),
Color(red: 255 / 255, green: 94 / 255, blue: 7 / 255)
])
case .confirm:
return ("Hold to confirm", "Nice, it's confirmed!", .green, [
Color(red: 0 / 255, green: 204 / 255, blue: 130 / 255),
Color(red: 56 / 255, green: 178 / 255, blue: 46 / 255)
])
case .delete:
return ("Hold to delete", "Nice, it's deleted!", .red, [
Color(red: 161 / 255, green: 0 / 255, blue: 63 / 255),
Color(red: 230 / 255, green: 61 / 255, blue: 66 / 255),
Color(red: 255 / 255, green: 127 / 255, blue: 79 / 255)
])
}
}
var progressGradient: LinearGradient {
LinearGradient(
gradient: Gradient(colors: data.progressGradientColors),
startPoint: .trailing,
endPoint: .leading
)
}
}
}
Last week, I’ve been thinking a lot about optimizing developers’ time and finding ways to eliminate the need to create common components from scratch for every project.
One of the first components I tackled was a holding button with three predefined states: .cancel
, .delete
, and .confirm.
This component not only saves time by simplifying the creation process but also helps avoid the need for additional confirmation popups, consolidating everything into one efficient button.
Here’s the code for the button. Feel free to use it in your projects:
struct HoldButton: View {
let holdButton: HoldButtonType
let onConfirm: () -> Void
@GestureState private var isPressing = false
@State private var backgroundColor: Color
@State private var progress: CGFloat = 0.0
@State private var scaleEffect: CGFloat = 1.0
@State private var progressTimer: Timer?
init(holdButton: HoldButtonType, onConfirm: @escaping () -> Void) {
self.holdButton = holdButton
self.onConfirm = onConfirm
self._backgroundColor = State(initialValue: holdButton.data.initialStyle)
}
var body: some View {
Label(title(),systemImage: icon())
.foregroundColor(progress == 1.0 ? holdButton.data.initialStyle : .white)
.fontWeight(.semibold)
.symbolEffect(.bounce, options: .speed(animationSpeed()).repeat(repeatCount()), value: isPressing)
.frame(maxWidth: .infinity)
.frame(height:52)
.background(backgroundColor)
.cornerRadius(12)
.overlay(progressOverlay)
.padding(.horizontal, 20)
.gesture(longPressGesture)
.onChange(of: isPressing) { handlePressChange() }
.scaleEffect(scaleEffect)
.animation(.easeInOut(duration: 0.5), value: scaleEffect)
}
private var progressOverlay: some View {
RoundedRectangle(cornerRadius: 12.0)
.trim(from: 0, to: progress)
.stroke(holdButton.progressGradient, lineWidth: progress == 1.0 ? 0 : 2)
.shadow(color: holdButton.data.progressGradientColors.first!, radius: 10, x: 0, y: 0)
.animation(.easeInOut, value: progress)
}
private var longPressGesture: some Gesture {
LongPressGesture(minimumDuration: 3.0)
.updating($isPressing) { currentState, gestureState, _ in
gestureState = currentState
}
}
private func title() -> String {
if progress == 1.0 {
return holdButton.data.finalTitle
} else if isPressing {
return "Keep holding..."
} else {
return holdButton.data.initialTitle
}
}
private func icon() -> String {
if progress == 1.0 {
return "checkmark"
} else if isPressing {
return "hand.tap.fill"
} else {
return "hand.tap"
}
}
private func animationSpeed() -> Double {
if progress == 1.0 {
return 0.2
} else if isPressing {
return 0.3
} else {
return 0
}
}
private func repeatCount() -> Int {
if isPressing {
return 2
} else {
return 0
}
}
private func handlePressChange() {
if isPressing {
moveProgress()
} else if progress < 1.0 {
resetProgress()
}
}
private func moveProgress() {
invalidateTimer()
progress = 0.0
scaleEffect = 1.0
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
if isPressing && progress < 1.0 {
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 0.95
progress += 0.01
updateBackgroundColor()
}
}
} else {
progress = min(progress, 1.0)
progressTimer?.invalidate()
if progress >= 1.0 {
performCompletionAnimation()
}
}
}
}
private func performCompletionAnimation() {
onConfirm()
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 1.1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.easeInOut) {
scaleEffect = 1.0
}
}
}
}
private func resetProgress() {
invalidateTimer()
DispatchQueue.main.async {
withAnimation(.easeInOut) {
scaleEffect = 1.0
progress = 0.0
updateBackgroundColor()
}
}
}
private func invalidateTimer() {
progressTimer?.invalidate()
}
private func updateBackgroundColor() {
let targetOpacity: Double = {
if progress == 1.0 {
return 0.2
} else if isPressing {
return 0.1
} else {
return 1.0
}
}()
withAnimation(.linear(duration: progress == 1.0 ? 1.0 : 2.0)) {
backgroundColor = holdButton.data.initialStyle.opacity(targetOpacity)
}
}
enum HoldButtonType {
case cancel
case confirm
case delete
var data: (initialTitle: String, finalTitle: String, initialStyle: Color, progressGradientColors: [Color]) {
switch self {
case .cancel:
return ("Hold to cancel", "Nice, it's canceled!", .orange, [
Color(red: 255 / 255, green: 197 / 255, blue: 61 / 255),
Color(red: 255 / 255, green: 94 / 255, blue: 7 / 255)
])
case .confirm:
return ("Hold to confirm", "Nice, it's confirmed!", .green, [
Color(red: 0 / 255, green: 204 / 255, blue: 130 / 255),
Color(red: 56 / 255, green: 178 / 255, blue: 46 / 255)
])
case .delete:
return ("Hold to delete", "Nice, it's deleted!", .red, [
Color(red: 161 / 255, green: 0 / 255, blue: 63 / 255),
Color(red: 230 / 255, green: 61 / 255, blue: 66 / 255),
Color(red: 255 / 255, green: 127 / 255, blue: 79 / 255)
])
}
}
var progressGradient: LinearGradient {
LinearGradient(
gradient: Gradient(colors: data.progressGradientColors),
startPoint: .trailing,
endPoint: .leading
)
}
}
}