// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import SwiftUI public struct SessionCarouselView_SwiftUI: View { @Binding var index: Int let isOutgoing: Bool var contentInfos: [Attachment] let numberOfPages: Int public init(index: Binding, isOutgoing: Bool, contentInfos: [Attachment]) { self._index = index self.isOutgoing = isOutgoing self.contentInfos = contentInfos self.numberOfPages = contentInfos.count let first = self.contentInfos.first! let last = self.contentInfos.last! self.contentInfos.append(first) self.contentInfos.insert(last, at: 0) } public var body: some View { HStack(spacing: 0) { ArrowView(index: $index, numberOfPages: numberOfPages, type: .decrement) .zIndex(1) PageView(index: $index, numberOfPages: self.numberOfPages) { ForEach(self.contentInfos) { attachment in MediaView_SwiftUI( attachment: attachment, isOutgoing: self.isOutgoing, shouldSupressControls: true, cornerRadius: 0 ) } } .aspectRatio(1, contentMode: .fit) ArrowView(index: $index, numberOfPages: numberOfPages, type: .increment) .zIndex(1) } } } struct ArrowView: View { @Binding var index: Int let numberOfPages: Int let maxIndex: Int let type: ArrowType enum ArrowType { case increment case decrement } init(index: Binding, numberOfPages: Int, type: ArrowType) { self._index = index self.numberOfPages = numberOfPages self.maxIndex = numberOfPages + 1 self.type = type } var body: some View { let imageName = self.type == .decrement ? "chevron.left" : "chevron.right" Button { if self.type == .decrement { decrement() } else { increment() } } label: { Image(systemName: imageName) .font(.system(size: 20)) .foregroundColor(themeColor: .textPrimary) .frame(width: 30, height: 30) } } func decrement() { withAnimation(.easeOut) { self.index -= 1 } if self.index == 0 { self.index = self.maxIndex - 1 } } func increment() { withAnimation(.easeOut) { self.index += 1 } if self.index == self.maxIndex { self.index = 1 } } } struct PageView: View where Content: View { @Binding var index: Int let numberOfPages: Int let maxIndex: Int let content: () -> Content @State private var offset = CGFloat.zero @State private var dragging = false init(index: Binding, numberOfPages: Int, @ViewBuilder content: @escaping () -> Content) { self._index = index self.numberOfPages = numberOfPages self.content = content self.maxIndex = numberOfPages + 1 } var body: some View { ZStack(alignment: .bottom) { GeometryReader { geometry in ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { self.content() .frame(width: geometry.size.width, height: geometry.size.height) } } .content.offset(x: self.offset(in: geometry), y: 0) .frame(width: geometry.size.width, alignment: .leading) .clipShape(RoundedRectangle(cornerRadius: 15)) .gesture( DragGesture(coordinateSpace: .local) .onChanged { value in self.dragging = true self.offset = -CGFloat(self.index) * geometry.size.width + value.translation.width } .onEnded { value in let predictedEndOffset = -CGFloat(self.index) * geometry.size.width + value.predictedEndTranslation.width let predictedIndex = Int(round(predictedEndOffset / -geometry.size.width)) self.index = self.clampedIndex(from: predictedIndex) withAnimation(.easeOut(duration: 0.2)) { self.dragging = false } // FIXME: This is a workaround for withAnimation() not having completion callback DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { switch self.index { case 0: self.index = self.maxIndex - 1 case self.maxIndex: self.index = 1 default: break } } } ) } .clipped() PageControl(index: $index, maxIndex: numberOfPages - 1) .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) } } func offset(in geometry: GeometryProxy) -> CGFloat { if self.dragging { return max(min(self.offset, 0), -CGFloat(self.maxIndex) * geometry.size.width) } else { return -CGFloat(self.index) * geometry.size.width } } func clampedIndex(from predictedIndex: Int) -> Int { let newIndex = min(max(predictedIndex, self.index - 1), self.index + 1) guard newIndex >= 0 else { return 0 } guard newIndex <= maxIndex else { return maxIndex } return newIndex } } struct PageControl: View { @Binding var index: Int let maxIndex: Int var body: some View { ZStack { Capsule() .foregroundColor(.init(white: 0, opacity: 0.4)) HStack(spacing: 4) { ForEach(0...maxIndex, id: \.self) { index in Circle() .fill(index == ((self.index - 1) % (self.maxIndex + 1)) ? Color.white : Color.gray) .frame(width: 7, height: 7) } } .padding(6) } .fixedSize(horizontal: true, vertical: true) .frame( maxWidth: .infinity, maxHeight: 19 ) } } struct SessionCarouselView_SwiftUI_Previews: PreviewProvider { @State static var index = 1 static var previews: some View { ZStack { Color.black.ignoresSafeArea() SessionCarouselView_SwiftUI( index: $index, isOutgoing: true, contentInfos: [ Attachment( variant: .standard, contentType: "jpeg", byteCount: 100 ), Attachment( variant: .standard, contentType: "jpeg", byteCount: 100 ) ] ) } } }