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