I have to develop a scrollable horizontal calendar-like view, something like this.
The final goal is to create an infinite scrollable view. I'm facing some challenges that I'm not sure how to address. I show you the code I have for now, that is far away from what it should be in the end:
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(datesToShow, id: \.self) { value in
VStack {
Text("\(value.day)")
Text(value.toString(.custom("MMMM")).capitalized)
.padding(.top)
}
.foregroundStyle(.white)
.frame(width: 100, height: 100)
.background(.purple)
}
}
}
.frame(height: 200)
.onAppear {
}
}
datesToShow
is an array of dates that contains dates between today - 10 days and today + 10 days. Now, the hard time starts here:
datesToShow
array so that they are automatically loaded (with the best performance of course)?Thanks!
Following up on my suggestion to use a carousel, here is an example implementation to show it working.
The dates are shown as separate views in a ZStack
, each with a computed x-offset. A drag gesture on the ZStack
allows the current date to be changed. When the drag is released, it snaps to the nearest date.
In this example there are 15 underlying views, which just go round and round. I found that it works best to have quite a lot more views than the number that are actually visible, so that when dragging fast you don't get a glimpse of dates in the process of being updated. It would probably be a good idea to make the number dependent on the width of the display, or at least make the size of each view dependent on the width, so that it works on a large display too.
Hope it helps!
EDIT Code updated to do it all it one view, a separate DateView
is not needed. This allows the same DateFormatter
to be used for all the dates.
EDIT2 Some variables renamed, to make it clearer how the drag/snap mechanism works.
EDIT3 I noticed that it was a bit glitchy when the drag position was half-way to another date. Code corrected to use the snapped position for computing the date to display, instead of the drag position.
struct ContentView: View {
let nPanels = 15
let panelSize: CGFloat = 100
let gapSize: CGFloat = 10
let baseDate = Date.now
let dayOfMonthFormatter = DateFormatter()
let monthNameFormatter = DateFormatter()
@State private var snappedDayOffset = 0
@State private var draggedDayOffset = Double.zero
init() {
dayOfMonthFormatter.dateFormat = "d"
monthNameFormatter.dateFormat = "MMMM"
}
private var positionWidth: CGFloat {
CGFloat(panelSize + gapSize)
}
private func xOffsetForIndex(index: Int) -> Double {
let midIndex = Double(nPanels / 2)
var dIndex = (Double(index) - draggedDayOffset - midIndex).truncatingRemainder(dividingBy: Double(nPanels))
if dIndex < -midIndex {
dIndex += Double(nPanels)
} else if dIndex > midIndex {
dIndex -= Double(nPanels)
}
return dIndex * positionWidth
}
private func dayAdjustmentForIndex(index: Int) -> Int {
let midIndex = nPanels / 2
var dIndex = (index - snappedDayOffset - midIndex) % nPanels
if dIndex < -midIndex {
dIndex += nPanels
} else if dIndex > midIndex {
dIndex -= nPanels
}
return dIndex + snappedDayOffset
}
private func dateView(index: Int, halfFullWidth: CGFloat) -> some View {
let xOffset = xOffsetForIndex(index: index)
let dayAdjustment = dayAdjustmentForIndex(index: index)
let dateToDisplay = Calendar.current.date(byAdding: .day, value: dayAdjustment, to: baseDate) ?? baseDate
return ZStack {
Color.purple
.cornerRadius(10)
VStack {
Text(dayOfMonthFormatter.string(from: dateToDisplay))
Text(monthNameFormatter.string(from: dateToDisplay))
}
.foregroundStyle(.white)
}
.frame(width: panelSize, height: panelSize)
.offset(x: xOffset)
// Setting opacity helps to avoid blinks when switching sides
.opacity(xOffset + positionWidth < -halfFullWidth || xOffset - positionWidth > halfFullWidth ? 0 : 1)
}
private var dragged: some Gesture {
DragGesture()
.onChanged() { val in
draggedDayOffset = Double(snappedDayOffset) - (val.translation.width / positionWidth)
}
.onEnded { val in
snappedDayOffset = Int(Double(snappedDayOffset) - (val.predictedEndTranslation.width / positionWidth).rounded())
withAnimation(.easeInOut(duration: 0.15)) {
draggedDayOffset = Double(snappedDayOffset)
}
}
}
var body: some View {
GeometryReader { proxy in
let halfFullWidth = proxy.size.width / 2
ZStack {
ForEach(0..<nPanels, id: \.self) { index in
dateView(index: index, halfFullWidth: halfFullWidth)
}
RoundedRectangle(cornerRadius: 10)
.stroke(lineWidth: 3)
.opacity(0.7)
.frame(width: positionWidth, height: positionWidth)
}
.gesture(dragged)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}