diff --git a/Sources/ACarousel/ACarousel.swift b/Sources/ACarousel/ACarousel.swift index 6b57441..f38002c 100644 --- a/Sources/ACarousel/ACarousel.swift +++ b/Sources/ACarousel/ACarousel.swift @@ -20,6 +20,16 @@ import SwiftUI +private extension View { + @ViewBuilder + func ifCondition(_ condition: Bool, then trueContent: (Self) -> TrueContent) -> some View { + if condition { + trueContent(self) + } else { + self + } + } +} @available(iOS 13.0, OSX 10.15, *) public struct ACarousel : View where Data : RandomAccessCollection, ID : Hashable, Content : View { @@ -48,6 +58,12 @@ public struct ACarousel : View where Data : RandomAccessColle viewModel.selectItem(data) } } + .ifCondition(viewModel.equalSpacing && viewModel.itemIsLeftOfSelected(data)) { view in + view.padding(.trailing, viewModel.paddingNextToSelected) + } + .ifCondition(viewModel.equalSpacing && viewModel.itemIsRightOfSelected(data)) { view in + view.padding(.leading, viewModel.paddingNextToSelected) + } case .nonUniform(let horizontal, let vertical): content(data) .frame(width: viewModel.itemWidth) @@ -57,8 +73,13 @@ public struct ACarousel : View where Data : RandomAccessColle viewModel.selectItem(data) } } - - } + .ifCondition(viewModel.equalSpacing && viewModel.itemIsLeftOfSelected(data)) { view in + view.padding(.trailing, viewModel.paddingNextToSelected) + } + .ifCondition(viewModel.equalSpacing && viewModel.itemIsRightOfSelected(data)) { view in + view.padding(.leading, viewModel.paddingNextToSelected) + } + } } } .frame(width: proxy.size.width, height: proxy.size.height, alignment: .leading) @@ -92,9 +113,13 @@ extension ACarousel { /// - autoScroll: A enum that define view to scroll automatically. See /// ``ACarouselAutoScroll``. default is `inactive`. /// - content: The view builder that creates views dynamically. - public init(_ data: Data, id: KeyPath, index: Binding = .constant(0), spacing: CGFloat = 10, headspace: CGFloat = 10, sidesScaling: ScaleMode = .uniform(factor: 0.8), isWrap: Bool = false, autoScroll: ACarouselAutoScroll = .inactive, canMove: Bool = true, tapToSelect: Bool = false, @ViewBuilder content: @escaping (Data.Element) -> Content) { + /// - equalSpacing: make space around selected item as wide as around + /// other items. + /// - tapToSelect: Allows tapping on other visible items in the carousel to + /// select them. + public init(_ data: Data, id: KeyPath, index: Binding = .constant(0), spacing: CGFloat = 10, headspace: CGFloat = 10, sidesScaling: ScaleMode = .uniform(factor: 0.8), isWrap: Bool = false, autoScroll: ACarouselAutoScroll = .inactive, canMove: Bool = true, equalSpacing: Bool = false, tapToSelect: Bool = false, @ViewBuilder content: @escaping (Data.Element) -> Content) { - self.viewModel = ACarouselViewModel(data, id: id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove, tapToSelect: tapToSelect) + self.viewModel = ACarouselViewModel(data, id: id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove, equalSpacing: equalSpacing, tapToSelect: tapToSelect) self.content = content } @@ -117,12 +142,14 @@ extension ACarousel where ID == Data.Element.ID, Data.Element : Identifiable { /// - isWrap: Define views to scroll through in a loop, default is false. /// - autoScroll: A enum that define view to scroll automatically. See /// ``ACarouselAutoScroll``. default is `inactive`. + /// - equalSpacing: make space around selected item as wide as around + /// other items. /// - tapToSelect: Allows tapping on other visible items in the carousel to - /// select them + /// select them. /// - content: The view builder that creates views dynamically. - public init(_ data: Data, index: Binding = .constant(0), spacing: CGFloat = 10, headspace: CGFloat = 10, sidesScaling: ScaleMode = .uniform(factor:0.8), isWrap: Bool = false, autoScroll: ACarouselAutoScroll = .inactive, canMove: Bool = true, tapToSelect: Bool = false, @ViewBuilder content: @escaping (Data.Element) -> Content) { + public init(_ data: Data, index: Binding = .constant(0), spacing: CGFloat = 10, headspace: CGFloat = 10, sidesScaling: ScaleMode = .uniform(factor:0.8), isWrap: Bool = false, autoScroll: ACarouselAutoScroll = .inactive, canMove: Bool = true, equalSpacing: Bool = false, tapToSelect: Bool = false, @ViewBuilder content: @escaping (Data.Element) -> Content) { - self.viewModel = ACarouselViewModel(data, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove, tapToSelect: tapToSelect) + self.viewModel = ACarouselViewModel(data, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove, equalSpacing: equalSpacing, tapToSelect: tapToSelect) self.content = content } diff --git a/Sources/ACarousel/ACarouselViewModel.swift b/Sources/ACarousel/ACarouselViewModel.swift index b222941..e43f8ef 100644 --- a/Sources/ACarousel/ACarouselViewModel.swift +++ b/Sources/ACarousel/ACarouselViewModel.swift @@ -37,13 +37,14 @@ class ACarouselViewModel: ObservableObject where Data : RandomAccessCo private let _dataId: KeyPath private let _spacing: CGFloat private let _headspace: CGFloat - private let _isWrap: Bool + private let _isWrap: Bool // TODO: when this is set to true, spacing is messed up private let _sidesScaling: ScaleMode private let _autoScroll: ACarouselAutoScroll private let _canMove: Bool + @Published var equalSpacing: Bool @Published var tapToSelect: Bool - init(_ data: Data, id: KeyPath, index: Binding, spacing: CGFloat, headspace: CGFloat, sidesScaling: ScaleMode, isWrap: Bool, autoScroll: ACarouselAutoScroll, canMove: Bool, tapToSelect: Bool) { + init(_ data: Data, id: KeyPath, index: Binding, spacing: CGFloat, headspace: CGFloat, sidesScaling: ScaleMode, isWrap: Bool, autoScroll: ACarouselAutoScroll, canMove: Bool, equalSpacing: Bool, tapToSelect: Bool) { guard index.wrappedValue < data.count else { fatalError("The index should be less than the count of data ") @@ -57,6 +58,7 @@ class ACarouselViewModel: ObservableObject where Data : RandomAccessCo self._sidesScaling = sidesScaling self._autoScroll = autoScroll self._canMove = canMove + self.equalSpacing = equalSpacing self.tapToSelect = tapToSelect if data.count > 1 && isWrap { @@ -112,8 +114,8 @@ class ACarouselViewModel: ObservableObject where Data : RandomAccessCo extension ACarouselViewModel where ID == Data.Element.ID, Data.Element : Identifiable { - convenience init(_ data: Data, index: Binding, spacing: CGFloat, headspace: CGFloat, sidesScaling: ScaleMode, isWrap: Bool, autoScroll: ACarouselAutoScroll, canMove: Bool, tapToSelect: Bool) { - self.init(data, id: \.id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove, tapToSelect: tapToSelect) + convenience init(_ data: Data, index: Binding, spacing: CGFloat, headspace: CGFloat, sidesScaling: ScaleMode, isWrap: Bool, autoScroll: ACarouselAutoScroll, canMove: Bool, equalSpacing: Bool, tapToSelect: Bool) { + self.init(data, id: \.id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove, equalSpacing: equalSpacing, tapToSelect: tapToSelect) } } @@ -121,13 +123,7 @@ extension ACarouselViewModel where ID == Data.Element.ID, Data.Element : Identif extension ACarouselViewModel { var data: Data { - guard _data.count != 0 else { - return _data - } - guard _data.count > 1 else { - return _data - } - guard isWrap else { + guard isWrap && _data.count > 1 else { return _data } return [_data.last!] + _data + [_data.first!] as! Data @@ -151,6 +147,15 @@ extension ACarouselViewModel { var itemWidth: CGFloat { max(0, viewSize.width - defaultPadding * 2) } + + var paddingNextToSelected: CGFloat { + switch _sidesScaling { + case .nonUniform(let horizontal, _): + return itemWidth * (1 - horizontal / 2.0) + case .uniform(let factor): + return itemWidth * (1 - factor / 2.0) + } + } var timer: TimePublisher? { guard autoScroll.isActive else { @@ -189,6 +194,28 @@ extension ACarouselViewModel { } } } + + /// returns true when the item is positioned next to the selected one on the left + func itemIsLeftOfSelected(_ item: Data.Element) -> Bool { + guard activeIndex < data.count && activeIndex >= 0 else { + return false + } + if let index = data.firstIndex(where: { $0[keyPath: _dataId] == item[keyPath: _dataId] }) { + return data.distance(from: activeIndex as! Data.Index, to: index) == -1 + } + return false + } + + /// returns true when the item is positioned next to the selected one on the right + func itemIsRightOfSelected(_ item: Data.Element) -> Bool { + guard activeIndex < data.count && activeIndex >= 0 else { + return false + } + if let index = data.firstIndex(where: { $0[keyPath: _dataId] == item[keyPath: _dataId] }) { + return data.distance(from: activeIndex as! Data.Index, to: index) == 1 + } + return false + } } // MARK: - private variable @@ -230,10 +257,15 @@ extension ACarouselViewModel { // MARK: - Offset Method extension ACarouselViewModel { + var offsetCorrection: CGFloat { + // only correct offset if there is an item to the left + paddingNextToSelected * CGFloat(activeIndex > 0 ? 1 : 0) + } + /// current offset value var offset: CGFloat { let activeOffset = CGFloat(activeIndex) * itemActualWidth - return defaultPadding - activeOffset + dragOffset + return defaultPadding - activeOffset + dragOffset - (equalSpacing ? offsetCorrection : 0) } /// change offset when acitveItem changes @@ -244,14 +276,14 @@ extension ACarouselViewModel { } let minimumOffset = defaultPadding - let maxinumOffset = defaultPadding - CGFloat(data.count - 1) * itemActualWidth + var maximumOffset = defaultPadding - CGFloat(data.count - 1) * itemActualWidth - (equalSpacing ? offsetCorrection : 0) if offset == minimumOffset { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.activeIndex = self.data.count - 2 self.isAnimatedOffset = false } - } else if offset == maxinumOffset { + } else if offset == maximumOffset { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.activeIndex = 1 self.isAnimatedOffset = false