Indicateur d'activité dans SwiftUI

91

Essayer d'ajouter un indicateur d'activité plein écran dans SwiftUI.

Je peux utiliser la .overlay(overlay: )fonction dans ViewProtocol.

Avec cela, je peux créer n'importe quelle superposition de vue, mais je ne trouve pas l' UIActivityIndicatorViewéquivalent du style par défaut iOS dans SwiftUI.

Comment puis-je créer un spinner de style par défaut avec SwiftUI?

REMARQUE: il ne s'agit pas d'ajouter un indicateur d'activité dans le cadre UIKit.

Johnykutty
la source
J'ai essayé de le trouver aussi, et j'ai échoué, je suppose qu'il sera ajouté plus tard :)
Markicevic
Assurez-vous de signaler un problème de rétroaction à Apple à l'aide de l'assistant de rétroaction. Obtenir des demandes au début du processus bêta est le meilleur moyen de voir ce que vous voulez dans le cadre.
Jon Shier
Vous pouvez trouver une version Native Standard entièrement personnalisable ici
Mojtaba Hosseini

Réponses:

213

Depuis Xcode 12 beta ( iOS 14 ), une nouvelle vue appelée ProgressViewest disponible pour les développeurs et peut afficher à la fois une progression déterminée et indéterminée.

Son style par défaut est CircularProgressViewStyle, ce qui est exactement ce que nous recherchons.

var body: some View {
    VStack {
        ProgressView()
           // and if you want to be explicit / future-proof...
           // .progressViewStyle(CircularProgressViewStyle())
    }
}

Xcode 11.x

Un certain nombre de vues ne sont pas encore représentées dans SwiftUI, mais il est facile de les porter dans le système. Vous devez emballer UIActivityIndicatoret le fabriquer UIViewRepresentable.

(Vous trouverez plus d'informations à ce sujet dans l'excellente conférence WWDC 2019 - Intégration de 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()
    }
}

Ensuite, vous pouvez l'utiliser comme suit - voici un exemple de superposition de chargement.

Remarque: je préfère utiliser ZStackplutôt que overlay(:_), donc je sais exactement ce qui se passe dans mon implémentation.

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)

            }
        }
    }

}

Pour le tester, vous pouvez utiliser cet exemple de 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)
            }
        }
    }

}

Résultat:

entrez la description de l'image ici

Matteo Pacini
la source
1
Mais comment l'arrêter?
Bagusflyer
1
Salut @MatteoPacini, Merci pour votre réponse. Mais, pouvez-vous s'il vous plaît m'aider comment puis-je masquer l'indicateur d'activité. Pouvez-vous s'il vous plaît mettre le code pour cela?
Annu
4
@Alfi dans son code dit isShowing: .constant(true). Cela signifie que l'indicateur est toujours affiché. Ce que vous devez faire est d'avoir une @Statevariable qui est vraie lorsque vous voulez que l'indicateur de chargement apparaisse (lorsque les données sont en cours de chargement), puis changez-la en fausse lorsque vous voulez que l'indicateur de chargement disparaisse (lorsque le chargement des données est terminé) . Si la variable est appelée isDataLoadingpar exemple, vous le feriez isShowing: $isDataLoadingau lieu de l'endroit où Matteo a mis isShowing: .constant(true).
RPatel99
1
@MatteoPacini vous n'avez pas réellement besoin d'une liaison pour cela car elle n'est pas modifiée dans ActivityIndicator ou dans LoadingView. Une simple variable booléenne fonctionne. La liaison est utile lorsque vous souhaitez modifier la variable à l'intérieur de la vue et renvoyer cette modification au parent.
Helam
1
@nelsonPARRILLA Je soupçonne que cela tintColorne fonctionne que sur les vues d'interface utilisateur Swift pures - pas sur celles pontées ( UIViewRepresentable).
Matteo Pacini
49

Si vous voulez une solution de style ui rapide , alors c'est la magie:

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(!self.isAnimating ? 1 - CGFloat(index) / 5 : 0.2 + CGFloat(index) / 5)
            .offset(y: geometry.size.width / 10 - geometry.size.height / 2)
          }.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
    }
  }
}

Simplement à utiliser:

ActivityIndicator()
.frame(width: 50, height: 50)

J'espère que cela aide!

Exemple d'utilisation:

ActivityIndicator()
.frame(size: CGSize(width: 200, height: 200))
    .foregroundColor(.orange)

entrez la description de l'image ici

KitKit
la source
Cela m'a beaucoup aidé, merci beaucoup! Vous pouvez définir des fonctions pour créer les cercles et ajouter un modificateur de vue pour les animations afin de les rendre plus lisibles.
Arif Ata Cengiz
2
J'adore cette solution!
Jon Vogel
1
Comment supprimer une animation si isAnimating est un état, peut-être un @Binding à la place?
Di Nerd
40

iOS 14 - Natif

c'est juste une vue simple.

ProgressView()

Actuellement, il est CircularProgressViewStyledéfini par défaut, mais vous pouvez en définir manuellement le style en ajoutant le modificateur suivant:

.progressViewStyle(CircularProgressViewStyle())

En outre, le style peut être tout ce qui est conforme à ProgressViewStyle


iOS 13 - Standard entièrement personnalisable UIActivityIndicatordans SwiftUI: (Exactement comme natif View):

Vous pouvez le construire et le configurer (autant que vous le pouviez dans l'original UIKit):

ActivityIndicator(isAnimating: loading)
    .configure { $0.color = .yellow } // Optional configurations (🎁 bones)
    .background(Color.blue)

Résultat


Implémentez simplement cette base structet vous serez prêt à partir:

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)
    }
}

🎁 Extension des os:

Avec cette petite extension utile, vous pouvez accéder à la configuration via un modifierSwiftUI comme les autres view:

extension View where Self == ActivityIndicator {
    func configure(_ configuration: @escaping (Self.UIView)->Void) -> Self {
        Self.init(isAnimating: self.isAnimating, configuration: configuration)
    }
}

La manière classique:

Vous pouvez également configurer la vue dans un initialiseur classique:

ActivityIndicator(isAnimating: loading) { 
    $0.color = .red
    $0.hidesWhenStopped = false
    //Any other UIActivityIndicatorView property you like
}

Cette méthode est totalement adaptable. Par exemple, vous pouvez voir Comment faire de TextField le premier répondeur avec la même méthode ici

Mojtaba Hosseini
la source
Comment changer la couleur de ProgressView?
Bagusflyer
.progressViewStyle(CircularProgressViewStyle(tint: Color.red))changera la couleur
Bagusflyer
5

Indicateurs personnalisés

Bien qu'Apple prenne désormais en charge l'indicateur d'activité natif à partir de SwiftUI 2.0, vous pouvez simplement implémenter vos propres animations. Ceux-ci sont tous pris en charge sur SwiftUI 1.0. En outre , il est fonctionne dans les widgets.

Les 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)
    }
}

Démo de différentes variantes Les arcs


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))
    }
}

Démo de différentes variantes Bars


Clignotants

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)
    }
}

Démo de différentes variantes Clignotants


Pour éviter les murs de code , vous pouvez trouver des indicateurs plus élégants dans ce dépôt hébergé sur le git .

Notez que toutes ces animations ont une bascule Bindingqui DOIT être exécutée.

Mojtaba Hosseini
la source
C'est bien! J'ai trouvé un bug cependant - il y a une animation vraiment étrange pouriActivityIndicator(style: .rotatingShapes(count: 10, size: 15))
pawello2222
quel est le problème avec le iActivityIndicator().style(.rotatingShapes(count: 10, size: 15))d'ailleurs? @ pawello2222?
Mojtaba Hosseini
Si vous définissez le countsur 5 ou moins, l'animation semble correcte (ressemble à cette réponse ). Cependant, si vous définissez le countsur 15, le point de début ne s'arrête pas en haut du cercle. Il commence à faire un autre cycle, puis est de nouveau vers le haut, puis commence le cycle recommence. Je ne sais pas si c'est prévu. Testé sur simulateur uniquement, Xcode 12.0.1.
pawello2222
Hmmmm. C'est parce que les animations ne sont pas sérialisées. Je devrais ajouter une option de sérialisation au framework pour cela. Merci d'avoir partagé votre avis.
Mojtaba Hosseini
@MojtabaHosseini comment basculer la liaison à exécuter?
GarySabo
4

J'ai implémenté l'indicateur UIKit classique en utilisant SwiftUI. Voir l'indicateur d'activité en action ici

struct ActivityIndicator: View {
  @State private var currentIndex: Int = 0

  func incrementIndex() {
    currentIndex += 1
    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50), execute: {
      self.incrementIndex()
    })
  }

  var body: some View {
    GeometryReader { (geometry: GeometryProxy) in
      ForEach(0..<12) { index in
        Group {
          Rectangle()
            .cornerRadius(geometry.size.width / 5)
            .frame(width: geometry.size.width / 8, height: geometry.size.height / 3)
            .offset(y: geometry.size.width / 2.25)
            .rotationEffect(.degrees(Double(-360 * index / 12)))
            .opacity(self.setOpacity(for: index))
        }.frame(width: geometry.size.width, height: geometry.size.height)
      }
    }
    .aspectRatio(1, contentMode: .fit)
    .onAppear {
      self.incrementIndex()
    }
  }

  func setOpacity(for index: Int) -> Double {
    let opacityOffset = Double((index + currentIndex - 1) % 11 ) / 12 * 0.9
    return 0.1 + opacityOffset
  }
}

struct ActivityIndicator_Previews: PreviewProvider {
  static var previews: some View {
    ActivityIndicator()
      .frame(width: 50, height: 50)
      .foregroundColor(.blue)
  }
}

Yisselda
la source
3

En plus de Mojatba Hosseini la réponse « ,

J'ai fait quelques mises à jour afin que cela puisse être mis dans un package rapide :

Indicateur d'activité:

import Foundation
import SwiftUI
import UIKit

public struct ActivityIndicator: UIViewRepresentable {

  public typealias UIView = UIActivityIndicatorView
  public var isAnimating: Bool = true
  public var configuration = { (indicator: UIView) in }

 public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) {
    self.isAnimating = isAnimating
    if let configuration = configuration {
        self.configuration = configuration
    }
 }

 public func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView {
    UIView()
 }

 public func updateUIView(_ uiView: UIView, context: 
    UIViewRepresentableContext<Self>) {
     isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
     configuration(uiView)
}}

Extension:

public extension View where Self == ActivityIndicator {
func configure(_ configuration: @escaping (Self.UIView) -> Void) -> Self {
    Self.init(isAnimating: self.isAnimating, configuration: configuration)
 }
}
Moyoteg
la source
2

Indicateur d'activité dans SwiftUI


import SwiftUI

struct Indicator: View {

    @State var animateTrimPath = false
    @State var rotaeInfinity = false

    var body: some View {

        ZStack {
            Color.black
                .edgesIgnoringSafeArea(.all)
            ZStack {
                Path { path in
                    path.addLines([
                        .init(x: 2, y: 1),
                        .init(x: 1, y: 0),
                        .init(x: 0, y: 1),
                        .init(x: 1, y: 2),
                        .init(x: 3, y: 0),
                        .init(x: 4, y: 1),
                        .init(x: 3, y: 2),
                        .init(x: 2, y: 1)
                    ])
                }
                .trim(from: animateTrimPath ? 1/0.99 : 0, to: animateTrimPath ? 1/0.99 : 1)
                .scale(50, anchor: .topLeading)
                .stroke(Color.yellow, lineWidth: 20)
                .offset(x: 110, y: 350)
                .animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true))
                .onAppear() {
                    self.animateTrimPath.toggle()
                }
            }
            .rotationEffect(.degrees(rotaeInfinity ? 0 : -360))
            .scaleEffect(0.3, anchor: .center)
            .animation(Animation.easeInOut(duration: 1.5)
            .repeatForever(autoreverses: false))
            .onAppear(){
                self.rotaeInfinity.toggle()
            }
        }
    }
}

struct Indicator_Previews: PreviewProvider {
    static var previews: some View {
        Indicator()
    }
}

Indicateur d'activité dans SwiftUI

Rashid Latif
la source
2

Essaye ça:

import SwiftUI

struct LoadingPlaceholder: View {
    var text = "Loading..."
    init(text:String ) {
        self.text = text
    }
    var body: some View {
        VStack(content: {
            ProgressView(self.text)
        })
    }
}

Plus d'informations sur SwiftUI ProgressView

Pedro Trujillo
la source
0
// Activity View

struct ActivityIndicator: UIViewRepresentable {

    let style: UIActivityIndicatorView.Style
    @Binding var animate: Bool

    private let spinner: UIActivityIndicatorView = {
        $0.hidesWhenStopped = true
        return $0
    }(UIActivityIndicatorView(style: .medium))

    func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
        spinner.style = style
        return spinner
    }

    func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
        animate ? uiView.startAnimating() : uiView.stopAnimating()
    }

    func configure(_ indicator: (UIActivityIndicatorView) -> Void) -> some View {
        indicator(spinner)
        return self
    }   
}

// Usage
struct ContentView: View {

    @State var animate = false

    var body: some View {
            ActivityIndicator(style: .large, animate: $animate)
                .configure {
                    $0.color = .red
            }
            .background(Color.blue)
    }
}
Manish
la source