diff --git a/Apps/Examples/Examples/FioriSwiftUICore/Card/MobileCardExample.swift b/Apps/Examples/Examples/FioriSwiftUICore/Card/MobileCardExample.swift index 1a4c5e198..a48bd0cca 100644 --- a/Apps/Examples/Examples/FioriSwiftUICore/Card/MobileCardExample.swift +++ b/Apps/Examples/Examples/FioriSwiftUICore/Card/MobileCardExample.swift @@ -4,6 +4,8 @@ import MapKit import SwiftUI struct MobileCardExample: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + var body: some View { List { NavigationLink { @@ -14,9 +16,9 @@ struct MobileCardExample: View { } .cardStyle(.card) .listStyle(.plain) - .navigationBarTitle("List", displayMode: .inline) + .navigationBarTitle("Cards", displayMode: .inline) } label: { - Text("List") + Text("Cards") } NavigationLink { @@ -25,6 +27,13 @@ struct MobileCardExample: View { } label: { Text("Masonry") } + + NavigationLink { + CarouselTestView(self.horizontalSizeClass == .compact ? 1 : (UIDevice.current.localizedModel == "iPhone" ? 2 : 3)) + .navigationBarTitle("Carousel", displayMode: .inline) + } label: { + Text("Carousel") + } } .navigationBarTitle("Cards", displayMode: .inline) } @@ -58,6 +67,94 @@ struct MasonryTestView: View { } } +extension VerticalAlignment: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self) + } +} + +struct CarouselTestView: View { + let defaultNumberOfColumns: Double + + @State var isPresented: Bool = false + @State var isSnapping: Bool = true + @State var numberOfColumns: Double + @State var spacing = 16.0 + @State var padding = 16.0 + @State var alignment = 0 + @State var contentType = 0 + + init(_ n: Double = 1) { + self.defaultNumberOfColumns = n + self._numberOfColumns = State(initialValue: n) + } + + var body: some View { + Carousel(numberOfColumns: Int(self.numberOfColumns), spacing: self.spacing, alignment: self.alignment == 0 ? .top : (self.alignment == 1 ? .center : .bottom), isSnapping: self.isSnapping) { + if self.contentType == 0 { + ForEach(0 ..< max(3, CardTests.cardSamples.count - 3), id: \.self) { i in + CardTests.cardSamples[i] + } + } else { + ForEach(0 ..< 20, id: \.self) { i in + Text("Text \(i)") + .font(.title) + .padding() + .frame(height: 100) + .background(Color.gray) + } + } + } + .cardStyle(.card) + .padding(self.padding) + .border(Color.gray) + .sheet(isPresented: self.$isPresented, content: { + VStack { + HStack { + Text("Content Type:") + Spacer() + Picker("Content Type", selection: self.$contentType) { + Text("Card").tag(0) + Text("Text").tag(1) + } + } + + HStack { + Text("numberOfColumns: \(Int(self.numberOfColumns))") + Slider(value: self.$numberOfColumns, in: 1 ... self.defaultNumberOfColumns + 2, step: 1) + } + HStack { + Text("spacing: \(Int(self.spacing))") + Slider(value: self.$spacing, in: 0 ... 20, step: 4) + } + HStack { + Text("padding: \(Int(self.padding))") + Slider(value: self.$padding, in: 0 ... 20, step: 4) + } + + HStack { + Text("Alignment:") + Spacer() + Picker("Alignment", selection: self.$alignment) { + Text("Top").tag(0) + Text("Center").tag(1) + Text("Bottom").tag(2) + } + } + + Toggle("isSnapping", isOn: self.$isSnapping) + } + .padding() + .presentationDetents([.medium]) + }) + .toolbar(content: { + FioriButton(title: "Options") { _ in + self.isPresented = true + } + }) + } +} + #Preview { List { ForEach(0 ..< CardTests.cardSamples.count, id: \.self) { i in diff --git a/Sources/FioriSwiftUICore/Views/Carousel.swift b/Sources/FioriSwiftUICore/Views/Carousel.swift new file mode 100644 index 000000000..f25682f8b --- /dev/null +++ b/Sources/FioriSwiftUICore/Views/Carousel.swift @@ -0,0 +1,255 @@ +import SwiftUI + +/// Internal use only +struct CarouselLayout: Layout { + struct CacheData { + var width: CGFloat + var height: CGFloat + var columns: [CGRect] + + mutating func clear() { + self.width = 0 + self.height = 0 + self.columns.removeAll() + } + } + + /// Number of columns + let numberOfColumns: Int + + /// Horizontal spacing between views + let spacing: CGFloat + + /// Vertical alignment in each column + let alignment: VerticalAlignment + + /// The point at which the origin of the content view is offset from the origin of the container view. + let contentOffset: CGPoint + + let isSizeOnly: Bool + + init(numberOfColumns: Int = 1, spacing: CGFloat = 8, alignment: VerticalAlignment = .top, contentOffset: CGPoint = .zero, isSizeOnly: Bool = false) { + self.numberOfColumns = max(1, numberOfColumns) + self.spacing = spacing + self.alignment = alignment + self.contentOffset = contentOffset + self.isSizeOnly = isSizeOnly + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize { + guard let containerWidth = proposal.width else { + return .zero + } + + self.calculateLayout(for: subviews, containerWidth: containerWidth, cache: &cache) + + if self.isSizeOnly { + return CGSize(width: cache.columns.last?.maxX ?? containerWidth, height: cache.height) + } else { + return CGSize(width: containerWidth, height: cache.height) + } + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) { + guard let containerWidth = proposal.width else { + return + } + self.calculateLayout(for: subviews, containerWidth: containerWidth, cache: &cache) + + for (i, column) in cache.columns.enumerated() { + let y: CGFloat + switch self.alignment { + case .top: + y = 0 + case .bottom: + y = cache.height - column.size.height + default: + y = (cache.height - column.size.height) / 2 + } + + let pt = CGPoint(x: column.origin.x + bounds.origin.x - self.contentOffset.x, y: y + bounds.origin.y) + + subviews[i].place(at: pt, proposal: ProposedViewSize(width: column.size.width, height: nil)) + } + } + + func makeCache(subviews: Subviews) -> CacheData { + CacheData(width: 0, height: 0, columns: []) + } + + func calculateLayout(for subviews: Subviews, containerWidth: CGFloat, cache: inout CacheData) { + guard cache.width != containerWidth, !subviews.isEmpty, containerWidth > 0 else { + return + } + cache.clear() + cache.width = containerWidth + + let itemWidth: CGFloat = (containerWidth - CGFloat(self.numberOfColumns + 2) * self.spacing) / CGFloat(self.numberOfColumns) + + let sizes = subviews.map { + $0.sizeThatFits(ProposedViewSize(width: itemWidth, height: nil)) + }.map { + if $0.width > itemWidth { + return CGSize(width: itemWidth, height: $0.width) + } + return $0 + } + + for (i, size) in sizes.enumerated() { + let x = CGFloat(i) * (itemWidth + self.spacing) + let pt = CGPoint(x: x, y: 0) + cache.height = max(cache.height, size.height) + cache.columns.append(CGRect(origin: pt, size: size)) + } + } +} + +/** + Carousel + A container view that arranges its child views horizontally, one after the other, with a protion of the next child view visible in the container. It allows users to swipe or scroll through the child views to view fiffeernt piece of content. + + ## Example Initialization and Configuration + ```swift + Carousel(numberOfColumns: 3, spacing: 8, alignment: .top, isSnapping: true) { + ForEach(0..<16, id: \.self) { i in + Text("Text \(i)") + .font(.title) + .padding() + .frame(height: 100) + .background(Color.gray) + } + } + .padding(8) + .border(Color.gray) + ``` + */ +public struct Carousel: View where Content: View { + /// Number of columns + let numberOfColumns: Int + + /// Horizontal spacing between views + let spacing: CGFloat + + /// Vertical alignment in the carousel + let alignment: VerticalAlignment + + /// Whether it stops at a right position that the first visible subview can be displayed fully after scrolling. + let isSnapping: Bool + + /// The views representing the content of the Carousel + var content: () -> Content + + @Environment(\.layoutDirection) var layoutDirection + + /// Carousel content offset + @State private var contentOffset = CGPoint.zero + + /// Carousel previous content offset + @State private var preContentOffset = CGPoint.zero + + /// Carousel content size + @State private var contentSize = CGSize.zero + + /// Carousel view size + @State private var viewSize = CGSize.zero + + /// Create a Carousel View + /// - Parameters: + /// - numberOfColumns: Number of columns. The default is 1. + /// - spacing: Horizontal spacing between views. The default is 8. + /// - alignment: Vertical alignment in the carousel. The default is `.top`. + /// - isSnapping: Whether it stops at a right position that the first visible subview can be displayed fully after scrolling. The default is `true`. + /// - content: The views representing the content of the Carousel + public init(numberOfColumns: Int = 1, spacing: CGFloat = 8, alignment: VerticalAlignment = .top, isSnapping: Bool = true, @ViewBuilder content: @escaping () -> Content) { + self.numberOfColumns = numberOfColumns + self.spacing = spacing + self.alignment = alignment + self.isSnapping = isSnapping + self.content = content + } + + public var body: some View { + CarouselLayout(numberOfColumns: self.numberOfColumns, spacing: self.spacing, alignment: self.alignment, contentOffset: self.contentOffset, isSizeOnly: false) { + self.content() + } + .modifier(SizeModifier()) + .onPreferenceChange(SizePreferenceKey.self) { size in + DispatchQueue.main.async { + self.viewSize = size + } + } + .background { + CarouselLayout(numberOfColumns: self.numberOfColumns, spacing: self.spacing, alignment: self.alignment, isSizeOnly: true) { + self.content() + } + .opacity(0.0) + .modifier(SizeModifier()) + .onPreferenceChange(SizePreferenceKey.self) { size in + DispatchQueue.main.async { + self.contentSize = size + } + } + } + .clipped() + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + self.contentOffset.x = self.preContentOffset.x + (self.layoutDirection == .leftToRight ? -1 : 1) * value.translation.width + } + .onEnded { value in + withAnimation(.easeOut(duration: 0.5)) { + let maxX = max(0, contentSize.width - self.viewSize.width) + let expectedX = max(0, preContentOffset.x + (self.layoutDirection == .leftToRight ? -1 : 1) * value.predictedEndTranslation.width) + var finalX = min(maxX, expectedX) + if self.isSnapping { + let itemWidth: CGFloat = (viewSize.width - CGFloat(self.numberOfColumns + 2) * self.spacing) / CGFloat(self.numberOfColumns) + let index = (expectedX / (itemWidth + self.spacing)).rounded() + finalX = max(0, min(maxX, index * (itemWidth + self.spacing) - self.spacing)) + } + + self.contentOffset.x = finalX + self.preContentOffset = self.contentOffset + } + } + ) + } +} + +#Preview { + Carousel(numberOfColumns: 3, spacing: 8, alignment: .top, isSnapping: true) { + ForEach(0 ..< 16, id: \.self) { i in + Text("Text \(i)") + .font(.title) + .padding() + .frame(height: 100) + .background(Color.gray) + } + } + .padding(8) + .border(Color.gray) + .environment(\.layoutDirection, .rightToLeft) +} + +#Preview { + Carousel(numberOfColumns: 2, spacing: 16, alignment: .bottom) { + ForEach(0 ..< CardTests.cardSamples.count, id: \.self) { i in + CardTests.cardSamples[i].border(Color.green) + } + } + .padding() + .border(Color.black) + .cardStyle(.card) +} + +#Preview { + ScrollView(.horizontal) { + LazyHStack { + ForEach(0 ..< CardTests.cardSamples.count, id: \.self) { i in + CardTests.cardSamples[i] + .frame(width: 200) + } + } + .cardStyle(.card) + }.padding() +} diff --git a/Sources/FioriSwiftUICore/_FioriStyles/CardStyle.fiori.swift b/Sources/FioriSwiftUICore/_FioriStyles/CardStyle.fiori.swift index e9295ed81..f140686d4 100644 --- a/Sources/FioriSwiftUICore/_FioriStyles/CardStyle.fiori.swift +++ b/Sources/FioriSwiftUICore/_FioriStyles/CardStyle.fiori.swift @@ -905,7 +905,7 @@ public enum CardTests { static let titleOnly = Card(title: "Title") /// Sample cards for testing - public static let cardSamples = [sampleCard1, sampleCard2, sampleCard3, sampleCard4, sampleCard5, sampleCard6, sampleCard7, sampleCard8, sampleCard9, sampleCard10, sampleCard11, vbCard, fullCard] + public static let cardSamples = [sampleCard1, sampleCard2, sampleCard3, sampleCard4, sampleCard5, sampleCard6, sampleCard7, sampleCard9, sampleCard10, vbCard, sampleCard11, sampleCard8, fullCard] static let previewCardSamples = [sampleCard1, sampleCard2, sampleCard3, sampleCard4, sampleCard5, sampleCard6, sampleCard7, sampleCard8, sampleCard9, sampleCard10, sampleCard11, vbCard, fullCard, headerOnly, titleOnly] }