swiftswiftui

Model function breaks Pinned Headers


I have a basic SwiftUI view that shows items releasing and they are grouped by date, each date has its own header that gets pinned and its releases. When the view initially appears the pinned headers work, however if I call the Model function getReleases() then the pinnedHeaders stop working as shown in the video below. I have tried updating State values after the function is called to allow the view to update, I also tried putting the function in a background thread. Note the service function does a basic Firebase query. Also the bug occurs even if the firebase query service function returns NO documents.

https://drive.google.com/file/d/1ZUoMF60jkXl5RigOAFp0SdnpJVIohRwj/view?usp=sharing

Model

import Foundation

@Observable class FeedModel {
    var releases = [ReleaseHolder]()
    var lastUpdatedReleases: Date? = nil
    var gotReleases = false

    func getReleases() {
        DispatchQueue.main.async {
            self.lastUpdatedReleases = Date()
        }
        
        DispatchQueue.global(qos: .background).async {
            ReleaseService().GetNewReleases { data in
                let calendar = Calendar.current
                let formatter = DateFormatter()
                
                formatter.dateFormat = "EEEE, MMMM d"
                
                let groupedReleases = Dictionary(grouping: data) { release -> String in
                    let date = release.releaseTime.dateValue()
                    return formatter.string(from: date)
                }
                
                var releaseHolders = groupedReleases.map { (dateString, releases) in
                    let firstReleaseDate = releases.first?.releaseTime.dateValue() ?? Date()
                    
                    let adjustedDateString: String
                    if calendar.isDateInToday(firstReleaseDate) {
                        adjustedDateString = "Today"
                    } else if calendar.isDateInTomorrow(firstReleaseDate) {
                        adjustedDateString = "Tomorrow"
                    } else {
                        adjustedDateString = formatter.string(from: firstReleaseDate)
                    }
                    
                    let sortedReleases = releases.sorted {
                        $0.releaseTime.dateValue() < $1.releaseTime.dateValue()
                    }
                    
                    return ReleaseHolder(dateString: adjustedDateString, releases: sortedReleases)
                }
                
                releaseHolders.sort {
                    let date1 = $0.releases.first?.releaseTime.dateValue() ?? Date.distantFuture
                    let date2 = $1.releases.first?.releaseTime.dateValue() ?? Date.distantFuture
                    return date1 < date2
                }
                
                DispatchQueue.main.async {
                    self.releases = releaseHolders
                    self.gotReleases = true
                }
            }
        }
    }
}

View

import SwiftUI

struct HomeFeedView: View {
    @Environment(FeedModel.self) private var model
    @Environment(\.colorScheme) var colorScheme
   
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(spacing: 10, pinnedViews: [.sectionHeaders]){
                    Color.clear.frame(height: 1).id("scrolltop")
                    
                    let data = getData()
                    
                    if data.isEmpty {
                         VStack(spacing: 12){
                             Text("Nothing yet...")
                         }
                    } else {
                        ForEach(data) { holder in
                            Section {
                                ForEach(holder.releases) { release in
                                    NavigationLink {
                                        ReleaseView(release: release)
                                    } label: {                                        
                                        FeedRowView()
                                    }
                                }
                            } header: {
                                HStack(spacing: 6){
                                    Text(holder.dateString).font(.headline).bold()
                                    Spacer()
                                }
                            }
                        }
                    }
                    
                    Color.clear.frame(height: 120)
                }
            }
            .safeAreaPadding(.top, 60 + top_Inset())
            .refreshable {
                model.getReleases()
            }
        }
        .overlay(alignment: .top) {
            headerView()
        }
        .ignoresSafeArea()
        .onAppear(perform: {
            model.getReleases()
        })
    }
    func getData() -> [ReleaseHolder] {
        if filter == "Past Drops" {
            return model.pastReleases
        }
        if filter == "No filter" {
            return model.releases
        }
        
        var final = [ReleaseHolder]()
        
        model.releases.forEach { element in
            var new = ReleaseHolder(dateString: element.dateString, releases: [])
            var newPosts = [Release]()
            
            element.releases.forEach { single in
                if (filter == "Sneakers" && single.type == 4) || (filter == "Apparel" && single.type == 3) || (filter == "Tickets" && single.type == 2) || (filter == "Collectibles" && single.type == 1) || (filter == "Electronics" && single.type == 5) {
                    newPosts.append(single)
                }
            }
            
            if !newPosts.isEmpty {
                new.releases = newPosts
                final.append(new)
            }
        }
        
        return final
    }
    @ViewBuilder
    func headerView() -> some View {
        ZStack {
            HStack {
                ZStack(alignment: .bottomTrailing){
                    NavigationLink {
                        ProfileView()
                    } label: {
                        ZStack {
                            Image(systemName: "person.crop.circle.fill")
                                .resizable()
                                .foregroundStyle(.gray)
                                .frame(width: 42, height: 42)
                                .clipShape(Circle())
                        }
                    }
                }
                Spacer()
            }
        }
    }
}

Solution

  • I managed to piece it together to get it working with some mock data and a mocked interface, since your code was not reproducible.

    As I mentioned in the comments, the issue here is likely with the async nature of the functions of the model, which are not in sync with the view state.

    I am not familiar with DispatchQueue so the code below uses more current methods using concurrency: async, await and Task.

    Basically, you just want to make sure the UI knows to wait for the completion of the calls, in order to update the view state at the right time. You can look at the .onAppear and .refreshable modifiers and maybe the model's getReleases() function on how it's done.

    Full code:

    import SwiftUI
    
    // Release and ReleaseHolder models
    struct Release: Identifiable {
        let id = UUID()
        let type: Int
        let name: String
        let releaseTime: Date
    }
    
    struct ReleaseHolder: Identifiable {
        let id = UUID()
        var dateString: String
        var releases: [Release]
    }
    
    @Observable class FeedModel {
        var releases = [ReleaseHolder]()
        var lastUpdatedReleases: Date? = nil
        var gotReleases = false
        
        func getReleases() async {
            // Simulate network call using async
            let sampleReleases = await fetchReleases()
            
            let groupedReleases = Dictionary(grouping: sampleReleases) { release -> String in
                let formatter = DateFormatter()
                formatter.dateFormat = "EEEE, MMMM d"
                return formatter.string(from: release.releaseTime)
            }
            
            let releaseHolders = groupedReleases.map { (dateString, releases) in
                ReleaseHolder(dateString: dateString, releases: releases)
            }
            
            // Update the UI on the main thread
            await MainActor.run {
                self.releases = releaseHolders
                self.gotReleases = true
            }
        }
        
        // Helper function simulating fetching data
        func fetchReleases() async -> [Release] {
            // Simulating a network delay
            try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
            
            let now = Date()
            
            let releases = (1...50).map { i -> Release in
                let releaseType = (i % 5) + 1  // Types 1 to 5
                let releaseName = "Release \(i)"
                let releaseTime = now.addingTimeInterval(Double(i * 86400))  // Sequential days (86400 seconds in a day)
                
                return Release(type: releaseType, name: releaseName, releaseTime: releaseTime)
            }
            
            return releases
        }
    }
    
    struct ReleaseHomeFeedView: View {
        @Environment(FeedModel.self) private var model
        @Environment(\.colorScheme) var colorScheme
        
        @State private var typeFilter: Int?
        
        var body: some View {
            NavigationStack {
                ScrollViewReader { proxy in
                    ScrollView {
                        LazyVStack(spacing: 10, pinnedViews: [.sectionHeaders]){
                            Color.clear.frame(height: 1).id("scrolltop")
                            
                            let data = getData()
                            
                            if data.isEmpty {
                                VStack(spacing: 12){
                                    Text("Nothing yet...")
                                }
                            } else {
                                ForEach(data) { holder in
                                    Section {
                                        ForEach(holder.releases) { release in
                                            NavigationLink {
                                                Text(release.name) // Dummy Detail View
                                            } label: {
                                                // Dummy Feed Row
                                                HStack(spacing: 20) {
                                                    Rectangle()
                                                        .fill(.blue)
                                                        .aspectRatio(1, contentMode: .fit)
                                                        .frame(width: 100)
                                                        .overlay {
                                                            Image(systemName: "photo.tv")
                                                                .foregroundStyle(.white)
                                                                .imageScale(.large)
                                                        }
                                                    VStack(alignment: .leading) {
                                                        Text(release.name)
                                                            .foregroundStyle(.secondary)
                                                        Text("Type: \(release.type)")
                                                            .foregroundStyle(.tertiary)
                                                        Text("Details")
                                                    }
                                                }
                                                .frame(maxWidth: .infinity, alignment: .leading)
                                                .background(.regularMaterial )
                                                .clipShape(RoundedRectangle(cornerRadius: 12))
                                                .padding(.leading)
                                            }
                                            .buttonStyle(.plain)
                                        }
                                    } header: {
                                        HStack(spacing: 6){
                                            Text(holder.dateString).font(.headline).bold()
                                                .padding(.horizontal)
                                            Spacer()
                                        }
                                        .padding(.vertical)
                                        .background(.thinMaterial)
                                    }
                                }
                            }
                        }
                    }
                    .refreshable {
                        await model.getReleases()
                        withAnimation {
                            proxy.scrollTo("scrolltop", anchor: .top)  // Scroll to top
                        }
                    }
                }
                .onAppear {
                    Task {
                        await model.getReleases()
                    }
                }
                .toolbarTitleDisplayMode(.inline)
                .toolbar {
                    
                    ToolbarItem(placement: .topBarLeading) {
                        Image(systemName: "person.crop.circle.fill")
                    }
                    
                    ToolbarItem(placement: .principal) {
                        Image(systemName: "crown.fill")
                    }
                    
                    ToolbarItem(placement: .primaryAction) {
                        Button {
                            typeFilter = typeFilter == nil ? 4 : nil
                        } label: {
                            Image(systemName: "line.3.horizontal.decrease")
                                .foregroundStyle(typeFilter == nil ? Color.primary : Color.blue)
                        }
                        .buttonStyle(.plain)
                    }
                    
                    ToolbarItem(placement: .primaryAction) {
                        Image(systemName: "chevron.down")
                    }
                }
            }
        }
        
        func getData() -> [ReleaseHolder] {
            
            guard let filter = typeFilter else {
                return model.releases
            }
            
            return model.releases.filter { holder in
                holder.releases.contains { $0.type == filter }
            }
        }
        
        @ViewBuilder
        func headerView() -> some View {
            HStack {
                NavigationLink {
                    Text("Profile view")
                } label: {
                    Image(systemName: "person.crop.circle.fill")
                        .resizable()
                        .foregroundStyle(.gray)
                        .frame(width: 42, height: 42)
                        .clipShape(Circle())
                }
                Spacer()
                Image(systemName: "crown.fill")
                    .font(.system(size: 50))
                Spacer()
            }
            .padding()
            .background(.regularMaterial)
        }
    }
    
    #Preview {
        @Previewable @State var model = FeedModel()
            ReleaseHomeFeedView()
                .environment(model)
    }
    

    enter image description here