TabView's page style is broken on iOS 17.4

I have an infinite week scroller implemented using a TabView's page styling.

basically when you scroll to the next week, it pre-loads the week after so that you can scroll infinitely.

Since iOS 17.4, it seems to partially scroll two pages ahead. Scrolling backwards works fine.

I made a radar: FB13718482

Here is a simplified implementation that has the issue reproduced. It uses the swift ordered collections library.

Video of the issue: https://youtu.be/JW8dHqawURA

import Foundation
import OrderedCollections
import SwiftUI

struct ContentView: View {
    private let calendar: Calendar
    private let dateFormatter: DateFormatter
    @State var weeks: OrderedDictionary<String, WeekView.Week>
    @State var selectedWeek: WeekView.Week.ID

    init() {
        let calendar = Calendar.autoupdatingCurrent
        self.calendar = calendar
        let formatter = DateFormatter()
        formatter.calendar = calendar
        formatter.dateFormat = "MMM d"
        dateFormatter = formatter

        // Setup initial week
        let currentDate = Date()
        let weekIdentifier = Self.weekIdentifier(for: currentDate, calendar: calendar)
        let weeks: OrderedDictionary<WeekView.Week.ID, WeekView.Week> = [
            weekIdentifier: Self.createWeek(for: currentDate, calendar: calendar)
        ]

        self._weeks = .init(initialValue: weeks)
        self._selectedWeek = .init(initialValue: weekIdentifier)
    }

    var body: some View {
        NavigationStack {
            TabView(selection: $selectedWeek) {
                ForEach(weeks.values) { week in
                    WeekView(week: week)
                        .tag(week.id)
                }
            }
            .onChange(of: selectedWeek, initial: true) { oldValue, newValue in
                createNextWeekIfRequired(for: weeks[newValue]!)
            }
            .tabViewStyle(.page(indexDisplayMode: .always))
            .indexViewStyle(.page(backgroundDisplayMode: .always))
            .navigationTitle(selectedWeek)
        }
        .environment(\.dateFormatter, dateFormatter)
    }

    private func createNextWeekIfRequired(for week: WeekView.Week) {
        guard let finalWeek = weeks.values.last, week.id == finalWeek.id, let day = finalWeek.days.first else {
            return
        }

        let nextWeek = calendar.date(byAdding: .weekOfYear, value: 1, to: day)!
        let identifier = Self.weekIdentifier(for: nextWeek, calendar: calendar)

        guard weeks[identifier] == nil else {
            return
        }

        weeks[identifier] = Self.createWeek(for: nextWeek, calendar: calendar)
    }

    static func weekIdentifier(for date: Date, calendar: Calendar) -> WeekView.Week.ID {
        let year = calendar.component(.yearForWeekOfYear, from: date)
        let week = calendar.component(.weekOfYear, from: date)
        return "\(year)-\(week)"
    }

    static func createWeek(for date: Date, calendar: Calendar) -> WeekView.Week {
        let startOfDay = calendar.startOfDay(for: date)
        let weekOfYear = calendar.component(.weekOfYear, from: startOfDay)

        let startOfWeek = calendar.nextDate(
            after: startOfDay + 1,
            matching: .init(hour: 0, minute: 0, second:0, nanosecond: 0, weekday: 1, weekOfYear: weekOfYear),
            matchingPolicy: .nextTime,
            direction: .backward
        )!

        var dates: [Date] = []

        calendar.enumerateDates(
            startingAfter: startOfWeek - 1,
            matching: .init(hour: 0, minute: 0, second:0, nanosecond: 0),
            matchingPolicy: .nextTime
        ) { result, exactMatch, stop in
            guard let result, calendar.component(.weekOfYear, from: result) == weekOfYear else {
                stop = true
                return
            }

            dates.append(result)
        }

        return WeekView.Week(id: weekIdentifier(for: date, calendar: calendar), days: dates)
    }
}

#Preview {
    ContentView()
}
import SwiftUI

struct WeekView: View {
    struct Week: Identifiable {
        var id: String
        var days: [Date]
    }

    var week: Week

    private let columnDefinition = [GridItem](
        repeating: GridItem(.flexible(minimum: 10, maximum: 200), alignment: .center),
        count: 7
    )

    var body: some View {
        LazyVGrid(columns: columnDefinition, alignment: .center) {
            ForEach(week.days, id: \.timeIntervalSinceReferenceDate) { date in
                DayView(date: date)
            }
        }
        .frame(maxWidth: .infinity)
    }
}
import SwiftUI

struct DayView: View {
    @Environment(\.dateFormatter) private var dateFormatter
    let date: Date

    var body: some View {
        VStack {
            Text(date, formatter: dateFormatter)
            Image(systemName: "calendar")
                .foregroundStyle(Color.blue)
        }
    }
}

#Preview {
    DayView(date: Date())
}
import Foundation
import SwiftUI

struct DateFormatterEnvironmentKey: EnvironmentKey {
    static var defaultValue: DateFormatter = {
        let formatter = DateFormatter()
        formatter.calendar = .autoupdatingCurrent
        formatter.dateFormat = "MMM d"
        return formatter
    }()
}

extension EnvironmentValues {
    var dateFormatter: DateFormatter {
        get { self[DateFormatterEnvironmentKey.self] }
        set { self[DateFormatterEnvironmentKey.self] = newValue }
    }
}
  • It seems the bug is related to updating the source of the foreach while scrolling.

    The only workaround I have found so far is to pre-populate all of the possible pages before hand. But for an endless scroller that does not make much sense.

Add a Comment