Skip to content

Instantly share code, notes, and snippets.

@balazserd
Last active May 6, 2020 17:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save balazserd/0bc66a99761f4b870f6f62a85c69b62f to your computer and use it in GitHub Desktop.
Save balazserd/0bc66a99761f4b870f6f62a85c69b62f to your computer and use it in GitHub Desktop.
Custom ActivityIndicator in SwiftUI
import SwiftUI
import Combine
struct ContentView: View {
@State private var isLoading: Bool = false
var body: some View {
VStack {
Spacer()
ActivityIndicator(isSpinning: self.$isLoading)
Spacer()
Button(action: { self.isLoading.toggle() }, label: { Text("Spin") })
Spacer()
}
}
}
struct ActivityIndicator: View {
//Will recalculate immediately upon published property change due to @ObservedObject.
@ObservedObject private var timerViewModel = ActivityIndicatorViewModel()
@Binding var isSpinning: Bool
var body: some View {
//If you always update this, you will get into an infinite loop, so check if value changed!
if (self.timerViewModel.isSpinning != self.isSpinning) {
self.timerViewModel.isSpinning = self.isSpinning
}
return VStack {
ZStack {
ForEach(0...11, id: \.self, content: { i in
//Every activity indicator line is made of two parts:
// One capsule above - the visible one
// Another capsule below - invisible, exists to lower the point where the bottom anchor
// that is used to rotate the VStacks is.
VStack(spacing: 0) {
//The visible part.
self.getCapsule(for: i)
//The invisible part.
Rectangle()
.frame(width: 1.5, height: 7)
.opacity(0.0)
}
//Rotates the capsules around their bottom
.rotationEffect(Angle(degrees: 30.0 * Double(i)),
anchor: .bottom)
})
}
}
}
private func getCapsule(for number: Int) -> some View {
//Simple capsule. Opacity changes only - this is what makes it "spinning".
Capsule()
.fill(Color.black)
.frame(width: 2.5, height: 7)
.opacity(self.getOpacity(for: number))
}
private func getOpacity(for number: Int) -> Double {
//Let's say currently the 9th line should be the one with full opacity (1).
//Then the one directly after (10th) should have the lowest opacity, and the one directly before (8th) the highest opacity (after the 9th).
//So, we calculate the difference between the full opacity line number and the currently drawn line number.
// e.g. diff for no.8 will be 8 - 9 = -1,
// diff for no.10 will be 10 - 9 = 1.
let diff = number - self.timerViewModel.fullOpacityCapsuleNumber
//We substract this from 12, so
// (12 - (-1)) = 13 for no.8
// (12 - 1) = 11 for no.10
//Then we get the remainder of dividing this number by 12, so
// remainder = 13 % 12 = 1 for no.8
// remainder = 11 % 12 = 11 for no.10
let remainder = ((12 - diff) % 12)
//The total amplitude in which opacity can change is 1 (highest) - 0.1 (lowest) = 0.9.
//Since we have 12 states, we need to distribute this whole amplitude to 12 different states.
// So, the opacity difference between lines next to each other will be 0.9 / 11.
//
//Then, we multiply [one unit of difference] with [relative distance (the "diff") from the full opacity line number]
// Thus we have the opacity.
return 1 - 0.9 / 11 * Double(remainder)
}
private class ActivityIndicatorViewModel : NSObject, ObservableObject {
//Making these properties @Published automatically notifies the View that it should recalculate itself.
//Making isSpinning Published also causes the main View's body to recalculate when it changes.
//But since we change it from when the main View's body is recalculated, we might get into an infinite loop.
//This is why the comment at the start of the main View's body is there to check whether the value actually changed.
@Published var isSpinning: Bool = false
@Published var fullOpacityCapsuleNumber: Int = 0
private var generalStore = Set<AnyCancellable>()
private var timerCancellable: AnyCancellable?
override init() {
super.init()
self.$isSpinning
.sink { [weak self] _isSpinning in
if _isSpinning {
//If isSpinning Binding changes to true, we start the timer which will make the indicator rotate.
self?.startTimer()
} else {
//If isSpinning Binding changes to false, we stop the timer.
self?.clearTimer()
}
}
.store(in: &self.generalStore)
}
private func clearTimer() {
//Cancel the subscription.
self.timerCancellable?.cancel()
self.timerCancellable = nil
}
private func startTimer() {
//Create a timer that fires every 1/12 second, so a total spin takes 1 second.
self.timerCancellable = Timer.publish(every: 1.0 / 12, on: .main, in: .default)
.autoconnect()
.sink { [weak self] _ in
//Whenever the timer fires, increase the line number which will be full opacity.
self?.fullOpacityCapsuleNumber += 1
if self?.fullOpacityCapsuleNumber == 12 {
//After the line no.11, comes the line number no.0 again.
self?.fullOpacityCapsuleNumber = 0
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment