Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions Sources/ACarousel/ACarousel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@

import SwiftUI

private extension View {
@ViewBuilder
func ifCondition<TrueContent: View>(_ condition: Bool, then trueContent: (Self) -> TrueContent) -> some View {
if condition {
trueContent(self)
} else {
self
}
}
}

@available(iOS 13.0, OSX 10.15, *)
public struct ACarousel<Data, ID, Content> : View where Data : RandomAccessCollection, ID : Hashable, Content : View {
Expand Down Expand Up @@ -48,6 +58,12 @@ public struct ACarousel<Data, ID, Content> : 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)
Expand All @@ -57,8 +73,13 @@ public struct ACarousel<Data, ID, Content> : 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)
Expand Down Expand Up @@ -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<Data.Element, ID>, index: Binding<Int> = .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<Data.Element, ID>, index: Binding<Int> = .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
}

Expand All @@ -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<Int> = .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<Int> = .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
}

Expand Down
60 changes: 46 additions & 14 deletions Sources/ACarousel/ACarouselViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@ class ACarouselViewModel<Data, ID>: ObservableObject where Data : RandomAccessCo
private let _dataId: KeyPath<Data.Element, ID>
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<Data.Element, ID>, index: Binding<Int>, spacing: CGFloat, headspace: CGFloat, sidesScaling: ScaleMode, isWrap: Bool, autoScroll: ACarouselAutoScroll, canMove: Bool, tapToSelect: Bool) {
init(_ data: Data, id: KeyPath<Data.Element, ID>, index: Binding<Int>, 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 ")
Expand All @@ -57,6 +58,7 @@ class ACarouselViewModel<Data, ID>: ObservableObject where Data : RandomAccessCo
self._sidesScaling = sidesScaling
self._autoScroll = autoScroll
self._canMove = canMove
self.equalSpacing = equalSpacing
self.tapToSelect = tapToSelect

if data.count > 1 && isWrap {
Expand Down Expand Up @@ -112,22 +114,16 @@ class ACarouselViewModel<Data, ID>: ObservableObject where Data : RandomAccessCo

extension ACarouselViewModel where ID == Data.Element.ID, Data.Element : Identifiable {

convenience init(_ data: Data, index: Binding<Int>, 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<Int>, 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)
}
}


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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down