Search code examples
arraysswiftdateswiftuicalendar

SwiftUI - Create scrollable horizontal calendar-like view


I have to develop a scrollable horizontal calendar-like view, something like this. enter image description here

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:

  1. How can I handle dates when I reach one of the two edges of the array/view? Today is Oct. 19th, how can I load dates previous to Oct. 9th or after Oct. 29th and add them to the datesToShow array so that they are automatically loaded (with the best performance of course)?
  2. Is this the way to achieve the result I need? Do you have different suggestion?

Thanks!


Solution

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

    Animation