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