SwiftUI Performance : optimiser LazyVStack et listes complexes

Techniques d'optimisation pour LazyVStack et listes SwiftUI. Réduire la consommation mémoire, améliorer le scrolling et éviter les pièges de performance courants.

Optimisation des performances LazyVStack et listes complexes en SwiftUI

Les listes représentent l'un des composants les plus utilisés dans les applications iOS. Avec SwiftUI, LazyVStack et List offrent des solutions performantes pour afficher des collections de données, mais leur utilisation incorrecte peut rapidement dégrader l'expérience utilisateur. Comprendre les mécanismes internes de ces composants permet d'éviter les pièges courants et de construire des interfaces fluides.

Ce que couvre cet article

Cet article présente les techniques d'optimisation essentielles pour les listes SwiftUI : lazy loading, recyclage des vues, gestion des identifiants et patterns avancés pour les données volumineuses.

Comprendre le lazy loading en SwiftUI

Le principe du lazy loading repose sur l'instanciation des vues uniquement lorsqu'elles deviennent visibles à l'écran. Contrairement à VStack qui crée immédiatement toutes ses vues enfants, LazyVStack diffère cette création, réduisant drastiquement la consommation mémoire et le temps de rendu initial.

LazyVStackComparison.swiftswift
import SwiftUI

// ❌ Problème : VStack instancie les 10 000 vues immédiatement
struct NonLazyListView: View {
    let items = (1...10000).map { "Item \($0)" }

    var body: some View {
        ScrollView {
            VStack {
                ForEach(items, id: \.self) { item in
                    // Chaque vue est créée au lancement
                    ExpensiveRowView(title: item)
                }
            }
        }
    }
}

// ✅ Solution : LazyVStack crée les vues à la demande
struct LazyListView: View {
    let items = (1...10000).map { "Item \($0)" }

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items, id: \.self) { item in
                    // Seules les vues visibles sont créées
                    ExpensiveRowView(title: item)
                }
            }
        }
    }
}

La différence de performance devient significative dès quelques centaines d'éléments. Avec 10 000 items, VStack peut prendre plusieurs secondes au lancement tandis que LazyVStack reste instantané.

Mesurer l'impact du lazy loading

Pour quantifier les gains de performance, Instruments permet de mesurer précisément l'utilisation mémoire et CPU. Voici une vue de test qui illustre la différence :

PerformanceMeasurement.swiftswift
struct ExpensiveRowView: View {
    let title: String

    // Simulation d'une initialisation coûteuse
    init(title: String) {
        self.title = title
        // Log pour visualiser quand la vue est créée
        print("Creating row: \(title)")
    }

    var body: some View {
        HStack {
            // Image avec traitement
            Circle()
                .fill(
                    LinearGradient(
                        colors: [.blue, .purple],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                )
                .frame(width: 50, height: 50)

            VStack(alignment: .leading) {
                Text(title)
                    .font(.headline)
                Text("Sous-titre avec calcul")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()
        }
        .padding()
    }
}

En exécutant avec VStack, les 10 000 logs s'affichent immédiatement. Avec LazyVStack, seuls les éléments visibles (environ 15-20 selon la taille d'écran) sont loggés, puis les suivants au fur et à mesure du scroll.

Comportement de rétention

LazyVStack conserve les vues déjà créées en mémoire après leur apparition. Contrairement à List qui recycle activement les cellules, les vues d'un LazyVStack persistent jusqu'à la destruction du composant parent.

Importance des identifiants stables

Les identifiants constituent le mécanisme central de mise à jour des listes SwiftUI. Un identifiant instable provoque des recréations inutiles de vues et peut causer des bugs visuels comme des animations incorrectes ou des pertes de position de scroll.

StableIdentifiers.swiftswift
// ❌ Problème : utiliser l'index comme identifiant
struct UnstableIdentifierView: View {
    @State private var items = ["A", "B", "C", "D"]

    var body: some View {
        List {
            // L'index change si un élément est supprimé
            ForEach(items.indices, id: \.self) { index in
                Text(items[index])
            }
        }
    }
}

// ❌ Problème : utiliser UUID() dans le ForEach
struct RegeneratedIdentifierView: View {
    let items = ["A", "B", "C", "D"]

    var body: some View {
        List {
            // UUID() génère un nouvel ID à chaque render
            ForEach(items, id: \.self) { item in
                // Problème subtil si items contient des doublons
                Text(item)
            }
        }
    }
}

// ✅ Solution : modèle avec identifiant stable
struct Item: Identifiable {
    let id: UUID  // Créé une seule fois
    var name: String

    init(name: String) {
        self.id = UUID()
        self.name = name
    }
}

struct StableIdentifierView: View {
    @State private var items = [
        Item(name: "A"),
        Item(name: "B"),
        Item(name: "C"),
        Item(name: "D")
    ]

    var body: some View {
        List {
            // id est stable pour toute la durée de vie de l'item
            ForEach(items) { item in
                Text(item.name)
            }
        }
    }
}

L'utilisation d'un identifiant unique et persistant garantit que SwiftUI peut correctement différencier les éléments lors des mises à jour, animations et comparaisons.

Optimisation des cellules avec Equatable

SwiftUI compare les vues pour déterminer si un re-render est nécessaire. Par défaut, cette comparaison utilise la réflexion, ce qui peut être coûteux. Implémenter Equatable permet une comparaison optimisée et explicite.

EquatableOptimization.swiftswift
// Modèle de données
struct Contact: Identifiable, Equatable {
    let id: UUID
    var name: String
    var email: String
    var avatarURL: URL?
    var lastActivity: Date

    // Comparaison personnalisée : ignorer lastActivity
    // si les autres propriétés sont identiques
    static func == (lhs: Contact, rhs: Contact) -> Bool {
        lhs.id == rhs.id &&
        lhs.name == rhs.name &&
        lhs.email == rhs.email &&
        lhs.avatarURL == rhs.avatarURL
        // lastActivity volontairement exclu
    }
}

// Vue de cellule optimisée
struct ContactRow: View, Equatable {
    let contact: Contact

    // Comparaison explicite pour éviter re-renders inutiles
    static func == (lhs: ContactRow, rhs: ContactRow) -> Bool {
        lhs.contact == rhs.contact
    }

    var body: some View {
        HStack(spacing: 12) {
            // Avatar asynchrone
            AsyncImage(url: contact.avatarURL) { phase in
                switch phase {
                case .success(let image):
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                case .failure:
                    Image(systemName: "person.circle.fill")
                        .foregroundStyle(.gray)
                default:
                    ProgressView()
                }
            }
            .frame(width: 44, height: 44)
            .clipShape(Circle())

            // Informations contact
            VStack(alignment: .leading, spacing: 2) {
                Text(contact.name)
                    .font(.body.weight(.medium))

                Text(contact.email)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()
        }
        .padding(.vertical, 4)
    }
}

// Liste utilisant EquatableView
struct ContactListView: View {
    let contacts: [Contact]

    var body: some View {
        List {
            ForEach(contacts) { contact in
                // EquatableView empêche les re-renders si contact inchangé
                EquatableView(content: ContactRow(contact: contact))
            }
        }
    }
}

Cette optimisation réduit significativement la charge CPU lors du scroll rapide, particulièrement avec des cellules contenant des calculs ou des images.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Gérer le chargement d'images asynchrone

Les images constituent souvent le goulot d'étranglement des performances de liste. Une gestion incorrecte provoque des saccades au scroll et une consommation mémoire excessive.

ImageLoadingOptimization.swiftswift
import SwiftUI

// Cache d'images singleton
actor ImageCache {
    static let shared = ImageCache()

    private var cache = NSCache<NSString, UIImage>()

    private init() {
        // Limite mémoire : 50 Mo
        cache.totalCostLimit = 50 * 1024 * 1024
    }

    func image(for url: URL) -> UIImage? {
        cache.object(forKey: url.absoluteString as NSString)
    }

    func setImage(_ image: UIImage, for url: URL) {
        // Estimation du coût : bytes de l'image
        let cost = Int(image.size.width * image.size.height * 4)
        cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
    }
}

// Vue d'image optimisée avec cache
struct CachedAsyncImage: View {
    let url: URL?
    let size: CGSize

    @State private var image: UIImage?
    @State private var isLoading = false

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } else if isLoading {
                Rectangle()
                    .fill(Color.gray.opacity(0.2))
                    .overlay(ProgressView())
            } else {
                Rectangle()
                    .fill(Color.gray.opacity(0.2))
            }
        }
        .frame(width: size.width, height: size.height)
        .clipped()
        .task(id: url) {
            await loadImage()
        }
    }

    private func loadImage() async {
        guard let url else { return }

        // Vérifier le cache
        if let cached = await ImageCache.shared.image(for: url) {
            self.image = cached
            return
        }

        isLoading = true
        defer { isLoading = false }

        // Télécharger et redimensionner
        do {
            let (data, _) = try await URLSession.shared.data(from: url)

            // Redimensionner pour économiser la mémoire
            if let original = UIImage(data: data),
               let resized = await resizeImage(original, to: size) {
                await ImageCache.shared.setImage(resized, for: url)
                self.image = resized
            }
        } catch {
            // Gérer l'erreur silencieusement
        }
    }

    private func resizeImage(_ image: UIImage, to size: CGSize) async -> UIImage? {
        // Utiliser le scale de l'écran
        let scale = await UIScreen.main.scale
        let targetSize = CGSize(
            width: size.width * scale,
            height: size.height * scale
        )

        return await withCheckedContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                let renderer = UIGraphicsImageRenderer(size: targetSize)
                let resized = renderer.image { _ in
                    image.draw(in: CGRect(origin: .zero, size: targetSize))
                }
                continuation.resume(returning: resized)
            }
        }
    }
}

Cette implémentation combine cache mémoire, redimensionnement préalable et chargement asynchrone pour une expérience de scroll fluide.

Prefetching intelligent pour anticipation

Pour les listes très longues, le prefetching permet de charger les images avant qu'elles ne deviennent visibles :

ImagePrefetching.swiftswift
// Coordinator de prefetching
@Observable
final class ImagePrefetcher {
    private var prefetchTasks: [URL: Task<Void, Never>] = [:]
    private let prefetchDistance = 10  // Nombre d'items en avance

    func prefetchImages(for items: [Contact], visibleRange: Range<Int>) {
        // Calculer la plage à précharger
        let prefetchStart = max(0, visibleRange.lowerBound - prefetchDistance)
        let prefetchEnd = min(items.count, visibleRange.upperBound + prefetchDistance)

        // Lancer le prefetch pour les items dans la plage
        for index in prefetchStart..<prefetchEnd {
            guard let url = items[index].avatarURL else { continue }

            // Éviter les doublons
            guard prefetchTasks[url] == nil else { continue }

            prefetchTasks[url] = Task {
                // Vérifier si déjà en cache
                if await ImageCache.shared.image(for: url) != nil {
                    return
                }

                // Précharger
                do {
                    let (data, _) = try await URLSession.shared.data(from: url)
                    if let image = UIImage(data: data) {
                        await ImageCache.shared.setImage(image, for: url)
                    }
                } catch {
                    // Ignorer les erreurs de prefetch
                }
            }
        }

        // Annuler les prefetch hors plage
        cancelOutOfRangePrefetches(validRange: prefetchStart..<prefetchEnd, items: items)
    }

    private func cancelOutOfRangePrefetches(validRange: Range<Int>, items: [Contact]) {
        let validURLs = Set(
            items[validRange].compactMap { $0.avatarURL }
        )

        for (url, task) in prefetchTasks {
            if !validURLs.contains(url) {
                task.cancel()
                prefetchTasks.removeValue(forKey: url)
            }
        }
    }
}

Utiliser List vs LazyVStack selon le contexte

Le choix entre List et LazyVStack dépend du cas d'usage. Chaque composant possède des avantages spécifiques qu'il convient de comprendre.

ListVsLazyVStack.swiftswift
// ✅ List : idéal pour les contenus interactifs
// - Recyclage automatique des cellules
// - Support natif de swipe actions
// - Séparateurs et styles prédéfinis
struct ContactsWithSwipeActions: View {
    @State private var contacts: [Contact] = []

    var body: some View {
        List {
            ForEach(contacts) { contact in
                ContactRow(contact: contact)
                    .swipeActions(edge: .trailing) {
                        Button(role: .destructive) {
                            deleteContact(contact)
                        } label: {
                            Label("Supprimer", systemImage: "trash")
                        }
                    }
                    .swipeActions(edge: .leading) {
                        Button {
                            favoriteContact(contact)
                        } label: {
                            Label("Favori", systemImage: "star")
                        }
                        .tint(.yellow)
                    }
            }
        }
        .listStyle(.plain)
    }

    private func deleteContact(_ contact: Contact) {
        contacts.removeAll { $0.id == contact.id }
    }

    private func favoriteContact(_ contact: Contact) {
        // Logique de favori
    }
}

// ✅ LazyVStack : idéal pour les layouts personnalisés
// - Contrôle total sur le spacing et padding
// - Pas de styles imposés
// - Meilleure performance pour l'affichage simple
struct CustomFeedView: View {
    let posts: [Post]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(posts) { post in
                    PostCard(post: post)
                }
            }
            .padding(.horizontal)
        }
    }
}

// Modèle Post pour l'exemple
struct Post: Identifiable {
    let id: UUID
    let author: String
    let content: String
    let imageURL: URL?
}

struct PostCard: View {
    let post: Post

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Header
            HStack {
                Circle()
                    .fill(Color.blue)
                    .frame(width: 40, height: 40)

                Text(post.author)
                    .font(.headline)

                Spacer()
            }

            // Contenu
            Text(post.content)

            // Image optionnelle
            if let imageURL = post.imageURL {
                CachedAsyncImage(url: imageURL, size: CGSize(width: 300, height: 200))
                    .cornerRadius(12)
            }
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(16)
        .shadow(color: .black.opacity(0.1), radius: 4, y: 2)
    }
}
Recyclage des cellules

List recycle activement les cellules, ce qui peut causer des problèmes avec les états locaux (@State). Les valeurs @State dans les cellules de List peuvent être réutilisées de manière inattendue. Préférer stocker l'état dans le modèle de données ou un ViewModel.

Sections et headers optimisés

L'organisation en sections améliore la lisibilité mais peut impacter les performances si mal implémentée. Les headers pinnés et la gestion des sections demandent une attention particulière.

OptimizedSections.swiftswift
// Modèle de données groupées
struct GroupedContacts {
    let letter: String
    let contacts: [Contact]
}

// Vue avec sections optimisées
struct SectionedContactList: View {
    let groupedContacts: [GroupedContacts]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
                ForEach(groupedContacts, id: \.letter) { group in
                    Section {
                        // Contenu de la section
                        ForEach(group.contacts) { contact in
                            ContactRow(contact: contact)
                                .padding(.horizontal)
                                .padding(.vertical, 8)

                            // Séparateur personnalisé
                            if contact.id != group.contacts.last?.id {
                                Divider()
                                    .padding(.leading, 68)
                            }
                        }
                    } header: {
                        // Header pinné optimisé
                        SectionHeader(title: group.letter)
                    }
                }
            }
        }
    }
}

// Header léger pour performance
struct SectionHeader: View {
    let title: String

    var body: some View {
        Text(title)
            .font(.headline)
            .foregroundStyle(.secondary)
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.horizontal)
            .padding(.vertical, 8)
            .background(.ultraThinMaterial)
    }
}

// Fonction de groupement optimisée
extension Array where Element == Contact {
    func groupedByFirstLetter() -> [GroupedContacts] {
        // Dictionnaire pour groupement O(n)
        var groups: [String: [Contact]] = [:]

        for contact in self {
            let letter = String(contact.name.prefix(1)).uppercased()
            groups[letter, default: []].append(contact)
        }

        // Trier les groupes alphabétiquement
        return groups
            .map { GroupedContacts(letter: $0.key, contacts: $0.value) }
            .sorted { $0.letter < $1.letter }
    }
}

Les headers pinnés (pinnedViews: [.sectionHeaders]) restent visibles lors du scroll, améliorant la navigation dans les longues listes.

Pagination et chargement infini

Pour les données volumineuses, la pagination évite de charger l'intégralité des données en mémoire. L'implémentation doit être transparente pour l'utilisateur.

InfiniteScrolling.swiftswift
// ViewModel gérant la pagination
@Observable
final class PaginatedListViewModel {
    private(set) var items: [Contact] = []
    private(set) var isLoading = false
    private(set) var hasMorePages = true

    private var currentPage = 0
    private let pageSize = 20
    private let dataService: ContactDataService

    init(dataService: ContactDataService) {
        self.dataService = dataService
    }

    func loadInitialData() async {
        guard items.isEmpty else { return }
        await loadNextPage()
    }

    func loadMoreIfNeeded(currentItem: Contact) async {
        // Déclencher le chargement quand on approche de la fin
        guard let index = items.firstIndex(where: { $0.id == currentItem.id }) else {
            return
        }

        // Charger 5 items avant la fin
        let thresholdIndex = items.count - 5

        if index >= thresholdIndex {
            await loadNextPage()
        }
    }

    private func loadNextPage() async {
        guard !isLoading, hasMorePages else { return }

        isLoading = true
        defer { isLoading = false }

        do {
            let newItems = try await dataService.fetchContacts(
                page: currentPage,
                limit: pageSize
            )

            items.append(contentsOf: newItems)
            currentPage += 1
            hasMorePages = newItems.count == pageSize
        } catch {
            // Gérer l'erreur
        }
    }
}

// Vue avec scroll infini
struct InfiniteContactList: View {
    @State private var viewModel: PaginatedListViewModel

    init(dataService: ContactDataService) {
        _viewModel = State(initialValue: PaginatedListViewModel(dataService: dataService))
    }

    var body: some View {
        List {
            ForEach(viewModel.items) { contact in
                ContactRow(contact: contact)
                    .task {
                        // Vérifier si besoin de charger plus
                        await viewModel.loadMoreIfNeeded(currentItem: contact)
                    }
            }

            // Indicateur de chargement en fin de liste
            if viewModel.isLoading {
                HStack {
                    Spacer()
                    ProgressView()
                    Spacer()
                }
                .padding()
            }
        }
        .task {
            await viewModel.loadInitialData()
        }
    }
}

// Protocole pour le service de données
protocol ContactDataService {
    func fetchContacts(page: Int, limit: Int) async throws -> [Contact]
}

Ce pattern assure un chargement fluide sans bloquer l'interface et permet une gestion efficace de la mémoire.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Profiling avec Instruments

Identifier les problèmes de performance nécessite des outils de mesure précis. Instruments fournit plusieurs templates adaptés à SwiftUI.

ProfilingHelpers.swiftswift
// Points de mesure pour le debugging
struct PerformanceMonitor {
    // Mesure du temps de création d'une vue
    static func measureViewCreation<T: View>(
        _ name: String,
        @ViewBuilder content: () -> T
    ) -> T {
        let start = CFAbsoluteTimeGetCurrent()
        let view = content()
        let elapsed = CFAbsoluteTimeGetCurrent() - start

        #if DEBUG
        if elapsed > 0.016 {  // Plus de 16ms = frame drop
            print("⚠️ [\(name)] View creation took \(elapsed * 1000)ms")
        }
        #endif

        return view
    }
}

// Extension pour tracer les renders
extension View {
    func debugRender(_ label: String) -> some View {
        #if DEBUG
        let _ = Self._printChanges()
        print("🔄 Rendering: \(label)")
        #endif
        return self
    }

    func measureRender(_ label: String) -> some View {
        modifier(RenderMeasureModifier(label: label))
    }
}

struct RenderMeasureModifier: ViewModifier {
    let label: String
    @State private var renderCount = 0

    func body(content: Content) -> some View {
        content
            .onAppear {
                renderCount += 1
                #if DEBUG
                print("📊 [\(label)] Render count: \(renderCount)")
                #endif
            }
    }
}

Checklist d'optimisation Instruments

Pour profiler efficacement une liste SwiftUI :

  1. Time Profiler : identifier les fonctions consommant le plus de CPU
  2. Allocations : vérifier la croissance mémoire lors du scroll
  3. SwiftUI Instrument : visualiser les body evaluations
  4. Core Animation : détecter les frames droppées
InstrumentsExample.swiftswift
// Vue instrumentée pour le profiling
struct ProfiledContactList: View {
    let contacts: [Contact]

    var body: some View {
        let _ = Self._printChanges()  // Affiche les changements déclenchant un render

        List {
            ForEach(contacts) { contact in
                ContactRow(contact: contact)
                    .measureRender("ContactRow-\(contact.id)")
            }
        }
    }
}
Self._printChanges()

Cette API de debugging SwiftUI affiche dans la console les propriétés ayant changé et déclenché une réévaluation du body. Indispensable pour identifier les re-renders inutiles.

Optimisations avancées avec drawingGroup

Pour les vues complexes avec de nombreux effets visuels, drawingGroup() peut améliorer significativement les performances en rastérisant la vue dans une couche Metal.

DrawingGroupOptimization.swiftswift
// Cellule avec effets visuels complexes
struct ComplexVisualRow: View {
    let item: Item

    var body: some View {
        HStack(spacing: 16) {
            // Cercle avec dégradé et ombre
            Circle()
                .fill(
                    RadialGradient(
                        colors: [.blue, .purple, .pink],
                        center: .center,
                        startRadius: 0,
                        endRadius: 25
                    )
                )
                .frame(width: 50, height: 50)
                .shadow(color: .purple.opacity(0.5), radius: 8, y: 4)

            VStack(alignment: .leading, spacing: 4) {
                Text(item.name)
                    .font(.headline)

                // Barre de progression avec dégradé
                GeometryReader { geometry in
                    Capsule()
                        .fill(Color.gray.opacity(0.2))
                        .overlay(alignment: .leading) {
                            Capsule()
                                .fill(
                                    LinearGradient(
                                        colors: [.green, .yellow, .orange],
                                        startPoint: .leading,
                                        endPoint: .trailing
                                    )
                                )
                                .frame(width: geometry.size.width * item.progress)
                        }
                }
                .frame(height: 8)
            }
        }
        .padding()
        // Rastérisation pour performance
        .drawingGroup()
    }
}

// Liste utilisant les cellules optimisées
struct OptimizedComplexList: View {
    let items: [Item]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                ForEach(items) { item in
                    ComplexVisualRow(item: item)
                }
            }
            .padding()
        }
    }
}

struct Item: Identifiable {
    let id: UUID
    let name: String
    let progress: Double
}

drawingGroup() est particulièrement efficace pour les vues avec dégradés, ombres et effets de flou combinés.

Conclusion

L'optimisation des listes SwiftUI repose sur une compréhension approfondie des mécanismes de lazy loading, de recyclage et de comparaison de vues. Les techniques présentées permettent de construire des interfaces capables de gérer des milliers d'éléments tout en maintenant une expérience de scroll fluide à 60 FPS.

Checklist performance SwiftUI

  • ✅ Utiliser LazyVStack ou List plutôt que VStack pour les collections
  • ✅ Implémenter Identifiable avec des IDs stables et uniques
  • ✅ Adopter Equatable pour les cellules complexes
  • ✅ Mettre en cache et redimensionner les images avant affichage
  • ✅ Précharger les données avec prefetching intelligent
  • ✅ Choisir List pour les interactions (swipe) ou LazyVStack pour les layouts custom
  • ✅ Utiliser pinnedViews pour les headers de section
  • ✅ Implémenter la pagination pour les données volumineuses
  • ✅ Profiler régulièrement avec Instruments
  • ✅ Appliquer drawingGroup() aux vues avec effets visuels complexes

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#swiftui
#ios
#performance
#lazyvstack
#swift

Partager

Articles similaires