Last active
May 6, 2020 17:15
-
-
Save balazserd/0bc66a99761f4b870f6f62a85c69b62f to your computer and use it in GitHub Desktop.
Custom ActivityIndicator in SwiftUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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