Activity indicator in SwiftUI
As of Xcode 12 beta (iOS 14), a new view called ProgressView
is available to developers, and that can display both determinate and indeterminate progress.
Its style defaults to CircularProgressViewStyle
, which is exactly what we're looking for.
var body: some View {
VStack {
ProgressView()
// and if you want to be explicit / future-proof...
// .progressViewStyle(CircularProgressViewStyle())
}
}
Xcode 11.x
Quite a few views are not yet represented in SwiftUI
, but it's easily to port them into the system.
You need to wrap UIActivityIndicator
and make it UIViewRepresentable
.
(More about this can be found in the excellent WWDC 2019 talk - Integrating SwiftUI)
struct ActivityIndicator: UIViewRepresentable {
@Binding var isAnimating: Bool
let style: UIActivityIndicatorView.Style
func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
return UIActivityIndicatorView(style: style)
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
}
}
Then you can use it as follows - here's an example of a loading overlay.
Note: I prefer using ZStack
, rather than overlay(:_)
, so I know exactly what's going on in my implementation.
struct LoadingView<Content>: View where Content: View {
@Binding var isShowing: Bool
var content: () -> Content
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .center) {
self.content()
.disabled(self.isShowing)
.blur(radius: self.isShowing ? 3 : 0)
VStack {
Text("Loading...")
ActivityIndicator(isAnimating: .constant(true), style: .large)
}
.frame(width: geometry.size.width / 2,
height: geometry.size.height / 5)
.background(Color.secondary.colorInvert())
.foregroundColor(Color.primary)
.cornerRadius(20)
.opacity(self.isShowing ? 1 : 0)
}
}
}
}
To test it, you can use this example code:
struct ContentView: View {
var body: some View {
LoadingView(isShowing: .constant(true)) {
NavigationView {
List(["1", "2", "3", "4", "5"], id: \.self) { row in
Text(row)
}.navigationBarTitle(Text("A List"), displayMode: .large)
}
}
}
}
Result:
iOS 14
it's just a simple view.
ProgressView()
Currently, it's defaulted to CircularProgressViewStyle
but you can manually set the style of it by adding the following modifer:
.progressViewStyle(CircularProgressViewStyle())
Also, the style could be anything that conforms to ProgressViewStyle
iOS 13 and above
Fully customizable Standard
UIActivityIndicator
in SwiftUI: (Exactly as a nativeView
):
You can build and configure it (as much as you could in the original UIKit
):
ActivityIndicator(isAnimating: loading)
.configure { $0.color = .yellow } // Optional configurations (🎁 bouns)
.background(Color.blue)
Just implement this base struct
and you will be good to go:
struct ActivityIndicator: UIViewRepresentable {
typealias UIView = UIActivityIndicatorView
var isAnimating: Bool
fileprivate var configuration = { (indicator: UIView) in }
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView { UIView() }
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<Self>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
configuration(uiView)
}
}
🎁 Bouns Extension:
With this little helpful extension, you can access the configuration through a modifier
like other SwiftUI view
s:
extension View where Self == ActivityIndicator {
func configure(_ configuration: @escaping (Self.UIView)->Void) -> Self {
Self.init(isAnimating: self.isAnimating, configuration: configuration)
}
}
The classic way:
Also you can configure the view in a classic initializer:
ActivityIndicator(isAnimating: loading) {
$0.color = .red
$0.hidesWhenStopped = false
//Any other UIActivityIndicatorView property you like
}
This method is fully adaptable. For example, you can see How to make TextField become the first responder with the same method here
If you want to a swift-ui-style solution, then this is the magic:
import Foundation
import SwiftUI
struct ActivityIndicator: View {
@State private var isAnimating: Bool = false
var body: some View {
GeometryReader { (geometry: GeometryProxy) in
ForEach(0..<5) { index in
Group {
Circle()
.frame(width: geometry.size.width / 5, height: geometry.size.height / 5)
.scaleEffect(calcScale(index: index))
.offset(y: calcYOffset(geometry))
}.frame(width: geometry.size.width, height: geometry.size.height)
.rotationEffect(!self.isAnimating ? .degrees(0) : .degrees(360))
.animation(Animation
.timingCurve(0.5, 0.15 + Double(index) / 5, 0.25, 1, duration: 1.5)
.repeatForever(autoreverses: false))
}
}
.aspectRatio(1, contentMode: .fit)
.onAppear {
self.isAnimating = true
}
}
func calcScale(index: Int) -> CGFloat {
return (!isAnimating ? 1 - CGFloat(Float(index)) / 5 : 0.2 + CGFloat(index) / 5)
}
func calcYOffset(_ geometry: GeometryProxy) -> CGFloat {
return geometry.size.width / 10 - geometry.size.height / 2
}
}
Simply to use:
ActivityIndicator()
.frame(width: 50, height: 50)
Hope it helps!
Example Usage:
ActivityIndicator()
.frame(size: CGSize(width: 200, height: 200))
.foregroundColor(.orange)
Custom Indicators
Although Apple supports native Activity Indicator now from the SwiftUI 2.0, You can Simply implement your own animations. These are all supported on SwiftUI 1.0. Also it is working in widgets.
Arcs
struct Arcs: View {
@Binding var isAnimating: Bool
let count: UInt
let width: CGFloat
let spacing: CGFloat
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
.rotationEffect(isAnimating ? .degrees(360) : .degrees(0))
.animation(
Animation.default
.speed(Double.random(in: 0.2...0.5))
.repeatCount(isAnimating ? .max : 1, autoreverses: false)
)
}
}
.aspectRatio(contentMode: .fit)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
Group { () -> Path in
var p = Path()
p.addArc(center: CGPoint(x: geometrySize.width/2, y: geometrySize.height/2),
radius: geometrySize.width/2 - width/2 - CGFloat(index) * (width + spacing),
startAngle: .degrees(0),
endAngle: .degrees(Double(Int.random(in: 120...300))),
clockwise: true)
return p.strokedPath(.init(lineWidth: width))
}
.frame(width: geometrySize.width, height: geometrySize.height)
}
}
Demo of different variations
Bars
struct Bars: View {
@Binding var isAnimating: Bool
let count: UInt
let spacing: CGFloat
let cornerRadius: CGFloat
let scaleRange: ClosedRange<Double>
let opacityRange: ClosedRange<Double>
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
}
}
.aspectRatio(contentMode: .fit)
}
private var scale: CGFloat { CGFloat(isAnimating ? scaleRange.lowerBound : scaleRange.upperBound) }
private var opacity: Double { isAnimating ? opacityRange.lowerBound : opacityRange.upperBound }
private func size(count: UInt, geometry: CGSize) -> CGFloat {
(geometry.width/CGFloat(count)) - (spacing-2)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.frame(width: size(count: count, geometry: geometrySize), height: geometrySize.height)
.scaleEffect(x: 1, y: scale, anchor: .center)
.opacity(opacity)
.animation(
Animation
.default
.repeatCount(isAnimating ? .max : 1, autoreverses: true)
.delay(Double(index) / Double(count) / 2)
)
.offset(x: CGFloat(index) * (size(count: count, geometry: geometrySize) + spacing))
}
}
Demo of different variations
Blinkers
struct Blinking: View {
@Binding var isAnimating: Bool
let count: UInt
let size: CGFloat
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
.aspectRatio(contentMode: .fit)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
let angle = 2 * CGFloat.pi / CGFloat(count) * CGFloat(index)
let x = (geometrySize.width/2 - size/2) * cos(angle)
let y = (geometrySize.height/2 - size/2) * sin(angle)
return Circle()
.frame(width: size, height: size)
.scaleEffect(isAnimating ? 0.5 : 1)
.opacity(isAnimating ? 0.25 : 1)
.animation(
Animation
.default
.repeatCount(isAnimating ? .max : 1, autoreverses: true)
.delay(Double(index) / Double(count) / 2)
)
.offset(x: x, y: y)
}
}
Demo of different variations
For the sake of preventing walls of code, you can find more elegant indicators in this repo hosted on the git.
Note that all these animations have a Binding
that MUST toggle to be run.