From 8a322513580f21288fd87eb3a1341c247b3e2a76 Mon Sep 17 00:00:00 2001 From: Autumn <15898848876@163.com> Date: Mon, 28 Dec 2020 12:46:24 +0800 Subject: [PATCH] add viewModel; add parameter "Index: Binding" --- .../ACarouselDemo iOS/ContentView.swift | 14 +- .../ACarouselDemo macOS/ContentView.swift | 1 + ACarouselDemo/ACarouselDemo macOS/Test.swift | 32 -- .../ACarouselDemo.xcodeproj/project.pbxproj | 8 +- README.md | 20 +- README.zh-CN.md | 21 +- Sources/ACarousel/ACarousel.swift | 430 +++--------------- Sources/ACarousel/ACarouselAutoScroll.swift | 52 +++ Sources/ACarousel/ACarouselViewModel.swift | 353 +++++++------- Sources/ACarousel/AppLifeCycleModifier.swift | 63 +++ Sources/ACarousel/AutoScroll.swift | 39 -- Sources/ACarousel/View+ReceiveTimer.swift | 41 ++ 12 files changed, 451 insertions(+), 623 deletions(-) delete mode 100644 ACarouselDemo/ACarouselDemo macOS/Test.swift create mode 100644 Sources/ACarousel/ACarouselAutoScroll.swift create mode 100644 Sources/ACarousel/AppLifeCycleModifier.swift delete mode 100644 Sources/ACarousel/AutoScroll.swift create mode 100644 Sources/ACarousel/View+ReceiveTimer.swift diff --git a/ACarouselDemo/ACarouselDemo iOS/ContentView.swift b/ACarouselDemo/ACarouselDemo iOS/ContentView.swift index 5c802e5..8436712 100644 --- a/ACarouselDemo/ACarouselDemo iOS/ContentView.swift +++ b/ACarouselDemo/ACarouselDemo iOS/ContentView.swift @@ -17,8 +17,6 @@ let roles = ["Luffy", "Zoro", "Sanji", "Nami", "Usopp", "Chopper", "Robin", "Fra struct ContentView: View { - let items: [Item] = roles.map { Item(image: Image($0)) } - @State var spacing: CGFloat = 10 @State var headspace: CGFloat = 10 @State var sidesScaling: CGFloat = 0.8 @@ -29,15 +27,17 @@ struct ContentView: View { var body: some View { VStack { - Spacer().frame(height: 50) - ACarousel(items, + Text("\(currentIndex + 1)/\(roles.count)") + Spacer().frame(height: 40) + ACarousel(roles, + id: \.self, + index: $currentIndex, spacing: spacing, - activeIndex: $currentIndex, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, - autoScroll: autoScroll ? .active(time) : .inactive) { item in - item.image + autoScroll: autoScroll ? .active(time) : .inactive) { name in + Image(name) .resizable() .scaledToFill() .frame(height: 300) diff --git a/ACarouselDemo/ACarouselDemo macOS/ContentView.swift b/ACarouselDemo/ACarouselDemo macOS/ContentView.swift index 7ce803b..816f18d 100644 --- a/ACarouselDemo/ACarouselDemo macOS/ContentView.swift +++ b/ACarouselDemo/ACarouselDemo macOS/ContentView.swift @@ -30,6 +30,7 @@ struct ContentView: View { VStack { Spacer().frame(height: 30) ACarousel(items, + index: .constant(2), spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, diff --git a/ACarouselDemo/ACarouselDemo macOS/Test.swift b/ACarouselDemo/ACarouselDemo macOS/Test.swift deleted file mode 100644 index 9faea1e..0000000 --- a/ACarouselDemo/ACarouselDemo macOS/Test.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Test.swift -// ACarouselDemo macOS -// -// Created by 帝云科技 on 2020/11/17. -// - -import SwiftUI -import ACarousel - -struct Test: View { - var body: some View { - VStack { - Image("Zoro") - .resizable() - .scaledToFill() - .frame(height: 100) - .clipped() - - ACarousel(Array(repeating: Item(image: Image("Zoro")), count: 3)) { _ in - Color.red - } - .frame(width: 300, height: 300, alignment: .center) - } - } -} - -struct Test_Previews: PreviewProvider { - static var previews: some View { - Test() - } -} diff --git a/ACarouselDemo/ACarouselDemo.xcodeproj/project.pbxproj b/ACarouselDemo/ACarouselDemo.xcodeproj/project.pbxproj index 18ab54e..1b880d7 100644 --- a/ACarouselDemo/ACarouselDemo.xcodeproj/project.pbxproj +++ b/ACarouselDemo/ACarouselDemo.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - EDC9AF3A2563982100321BC0 /* Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC9AF392563982100321BC0 /* Test.swift */; }; EDC9AF3F2563A19300321BC0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EDF64C97256241320050A86D /* Assets.xcassets */; }; EDF64C92256241310050A86D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF64C91256241310050A86D /* AppDelegate.swift */; }; EDF64C94256241310050A86D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF64C93256241310050A86D /* SceneDelegate.swift */; }; @@ -24,7 +23,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - EDC9AF392563982100321BC0 /* Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test.swift; sourceTree = ""; }; EDF64C8E256241310050A86D /* ACarouselDemo iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ACarouselDemo iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; EDF64C91256241310050A86D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; EDF64C93256241310050A86D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -121,7 +119,6 @@ EDF64D68256380F10050A86D /* Info.plist */, EDF64D69256380F10050A86D /* ACarouselDemo_macOS.entitlements */, EDF64D62256380F10050A86D /* Preview Content */, - EDC9AF392563982100321BC0 /* Test.swift */, ); path = "ACarouselDemo macOS"; sourceTree = ""; @@ -253,7 +250,6 @@ files = ( EDF64D5D256380F10050A86D /* AppDelegate.swift in Sources */, EDF64DA425638FC50050A86D /* ContentView.swift in Sources */, - EDC9AF3A2563982100321BC0 /* Test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -349,7 +345,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = "ACarouselDemo iOS/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -419,7 +415,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = "ACarouselDemo iOS/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/README.md b/README.md index a3cc08f..9452054 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A carousel view for SwiftUI -English version | [中文文档](README.zh-CN.md) +[中文文档](README.zh-CN.md)

@@ -43,7 +43,7 @@ Open `Xcode`, go to `File -> Swift Packages -> Add Package Dependency` and enter You can also add `ACarousel` as a dependency to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/JWAutumn/ACarousel", from: "0.1.3") + .package(url: "https://github.com/JWAutumn/ACarousel", from: "0.2.0") ] ``` @@ -78,7 +78,21 @@ struct ContentView: View { } } ``` - +or: +```swift +... +var body: some View { + ACarousel(roles, id: \.self) { name in + Image(name) + .resizable() + .scaledToFill() + .frame(height: 300) + .cornerRadius(30) + } + .frame(height: 300) +} +... +``` - Customize configuration: You can configure the corresponding parameters to customize the display style according to your needs. ```swift /// ... diff --git a/README.zh-CN.md b/README.zh-CN.md index 0cb1a72..21f2e05 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ `SwiftUI` 旋转木马效果 -[English version](README.md) | 中文文档 [详细分析](https://juejin.cn/post/6898258968775245837) +[English Version](README.md) | [详细解析](https://juejin.cn/post/6898258968775245837)

@@ -43,7 +43,7 @@ 也可以将 `ACarousel` 作为依赖添加项到你的 `Package.swift` 中: ```swift dependencies: [ - .package(url: "https://github.com/JWAutumn/ACarousel", from: "0.1.3") + .package(url: "https://github.com/JWAutumn/ACarousel", from: "0.2.0") ] ``` @@ -82,6 +82,23 @@ struct ContentView: View { } ``` +或者: + +```swift +... +var body: some View { + ACarousel(roles, id: \.self) { name in + Image(name) + .resizable() + .scaledToFill() + .frame(height: 300) + .cornerRadius(30) + } + .frame(height: 300) +} +... +``` + - 自定义参数:根据自身需求,你可以修改相应的参数来自定义显示样式。 ```swift /// ... diff --git a/Sources/ACarousel/ACarousel.swift b/Sources/ACarousel/ACarousel.swift index 650044c..42ca382 100644 --- a/Sources/ACarousel/ACarousel.swift +++ b/Sources/ACarousel/ACarousel.swift @@ -19,63 +19,42 @@ */ import SwiftUI -import Combine - -@available(iOS 13.0, OSX 10.15, *) -typealias TimePublisher = Publishers.Autoconnect @available(iOS 13.0, OSX 10.15, *) -public struct ACarousel : View where Data : RandomAccessCollection, Content : View, Data.Element : Identifiable { - -// public enum AutoScroll { -// case inactive -// case active(TimeInterval) -// } +public struct ACarousel : View where Data : RandomAccessCollection, ID : Hashable, Content : View { -// private let _data: [Data.Element] -// private let _spacing: CGFloat -// private let _headspace: CGFloat -// private let _isWrap: Bool -// private let _sidesScaling: CGFloat -// private let _autoScroll: AutoScroll + @ObservedObject + private var viewModel: ACarouselViewModel private let content: (Data.Element) -> Content -// private var timer: TimePublisher? = nil - -// @ObservedObject private var aState = AState() - @ObservedObject private var aState: ACarouselViewModel - public var body: some View { GeometryReader { proxy -> AnyView in - aState.viewSize = proxy.size + viewModel.viewSize = proxy.size return AnyView(generateContent(proxy: proxy)) }.clipped() } private func generateContent(proxy: GeometryProxy) -> some View { - HStack(spacing: aState.spacing) { - ForEach(aState.data) { + HStack(spacing: viewModel.spacing) { + ForEach(viewModel.data, id: viewModel.dataId) { content($0) - .frame(width: aState.itemWidth(proxy)) - .scaleEffect(x: 1, y: aState.itemScale($0), anchor: .center) + .frame(width: viewModel.itemWidth) + .scaleEffect(x: 1, y: viewModel.itemScaling($0), anchor: .center) } } .frame(width: proxy.size.width, height: proxy.size.height, alignment: .leading) - .offset(x: aState.offsetValue(proxy)) - .gesture(aState.dragGesture(proxy)) - .animation(aState.offsetAnimation) - .onReceive(timer: aState.timer, perform: receiveTimer) - .onReceiveAppLifeCycle { aState.isTimerActive = $0 } - .onReceive(aState.$activeItem) { _ in - aState.offsetChanged(aState.offsetValue(proxy), proxy: proxy) - } + .offset(x: viewModel.offset) + .gesture(viewModel.dragGesture) + .animation(viewModel.offsetAnimation) + .onReceive(timer: viewModel.timer, perform: viewModel.receiveTimer) + .onReceiveAppLifeCycle(perform: viewModel.setTimerActive) } - } // MARK: - Initializers + @available(iOS 13.0, OSX 10.15, *) extension ACarousel { @@ -83,371 +62,64 @@ extension ACarousel { /// updates based on the identity of the underlying data. /// /// - Parameters: - /// - data: The identified data that the ``ACarousel`` instance uses to - /// create views dynamically. + /// - data: The data that the ``ACarousel`` instance uses to create views + /// dynamically. + /// - id: The key path to the provided data's identifier. + /// - index: The index of currently active. /// - spacing: The distance between adjacent subviews, default is 10. /// - headspace: The width of the exposed side subviews, default is 10 /// - sidesScaling: The scale of the subviews on both sides, limits 0...1, - /// default is 0.8. + /// default is 0.8. /// - isWrap: Define views to scroll through in a loop, default is false. /// - autoScroll: A enum that define view to scroll automatically. See - /// ``ACarousel.AutoScroll``. default is `inactive`. + /// ``ACarouselAutoScroll``. default is `inactive`. /// - content: The view builder that creates views dynamically. - public init(_ data: Data, spacing: CGFloat = 10, activeIndex: Binding, headspace: CGFloat = 10, sidesScaling: CGFloat = 0.8, isWrap: Bool = false, autoScroll: AutoScroll = .inactive, - @ViewBuilder content: @escaping (Data.Element) -> Content) { + public init(_ data: Data, id: KeyPath, index: Binding = .constant(0), spacing: CGFloat = 10, headspace: CGFloat = 10, sidesScaling: CGFloat = 0.8, isWrap: Bool = false, autoScroll: ACarouselAutoScroll = .inactive, @ViewBuilder content: @escaping (Data.Element) -> Content) { - self.aState = ACarouselViewModel(data, spacing: spacing, activeIndex: activeIndex, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll) + self.viewModel = ACarouselViewModel(data, id: id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll) self.content = content - -// self._data = data.map { $0 } -// self._spacing = spacing -// self._headspace = headspace -// self._isWrap = isWrap -// self._sidesScaling = sidesScaling -// self._autoScroll = autoScroll -// self.content = content -// -// if !self.isWrap { -// aState = AState(activeItem: 0) -// } -// if self.autoScroll.isActive { -// timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() -// } - } -} - -/** -// MARK: - Private value -@available(iOS 13.0, OSX 10.15, *) -extension ACarousel { - - private var data: [Data.Element] { - guard _data.count != 0 else { - return _data - } - guard _data.count > 1 else { - return _data - } - guard isWrap else { - return _data - } - return [_data.last!] + _data + [_data.first!] - } - - private var spacing: CGFloat { - return _spacing - } - - private var headspace: CGFloat { - return _headspace - } - - private var sidesScaling: CGFloat { - return max(min(_sidesScaling, 1), 0) - } - - private var isWrap: Bool { - return _data.count > 1 ? _isWrap : false - } - - private var autoScroll: AutoScroll { - guard _data.count > 1 else { return .inactive } - guard case let .active(t) = _autoScroll else { return _autoScroll } - return t > 0 ? _autoScroll : .defaultActive - } - - private var offsetAnimation: Animation? { - return aState.animation ? .spring() : .none - } - - private var defaultPadding: CGFloat { - return (headspace + spacing) - } - - /// with of subview - private func itemWidth(_ proxy: GeometryProxy) -> CGFloat { - proxy.size.width - defaultPadding * 2 - } - - private func itemSize(_ proxy: GeometryProxy) -> CGFloat { - itemWidth(proxy) + spacing - } - - private func itemScale(_ item: Data.Element) -> CGFloat { - guard aState.activeItem < data.count else { - return 0 - } - return AnyHashable(data[aState.activeItem].id) == AnyHashable(item.id) ? 1 : sidesScaling } } - */ -/** -// MARK: - Offset Method @available(iOS 13.0, OSX 10.15, *) -extension ACarousel { +extension ACarousel where ID == Data.Element.ID, Data.Element : Identifiable { - private func offsetValue(_ proxy: GeometryProxy) -> CGFloat { - let activeOffset = CGFloat(aState.activeItem) * aState.itemSize(proxy) - let value = aState.defaultPadding - activeOffset + aState.dragOffset - return value - } - - private func offsetChanged(_ newOffset: CGFloat, proxy: GeometryProxy) { - aState.isAnimationOffset = true - guard aState.isWrap else { - return - } - let minOffset = aState.defaultPadding - let maxOffset = (aState.defaultPadding - CGFloat(aState.data.count - 1) * aState.itemSize(proxy)) - if newOffset == minOffset { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - aState.activeItem = aState.data.count - 2 - aState.isAnimationOffset = false - } - } else if newOffset == maxOffset { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - aState.activeItem = 1 - aState.isAnimationOffset = false - } - } - } -} - */ - -/*** -// MARK: - Drag Method -@available(iOS 13.0, OSX 10.15, *) -extension ACarousel { - - private func dragGesture(_ proxy: GeometryProxy) -> some Gesture { - DragGesture() - .onChanged { dragChanged($0, proxy: proxy) } - .onEnded { dragEnded($0, proxy: proxy) } - } - - private func dragChanged(_ value: DragGesture.Value, proxy: GeometryProxy) { - - /// Defines the maximum value of the drag - /// Avoid dragging more than the values of multiple subviews at the end of the drag, - /// and still only one subview is toggled - var offset: CGFloat = aState.itemSize(proxy) - if value.translation.width > 0 { - offset = min(offset, value.translation.width) - } else { - offset = max(-offset, value.translation.width) - } - - aState.dragChanged(offset) - } - - private func dragEnded(_ value: DragGesture.Value, proxy: GeometryProxy) { - aState.dragEnded() - - /// Defines the drag threshold - /// At the end of the drag, if the drag value exceeds the drag threshold, - /// the active view will be toggled - /// default is one third of subview - let dragThreshold: CGFloat = aState.itemWidth(proxy) / 3 - - var activeItem = aState.activeItem + /// Creates an instance that uniquely identifies and creates views across + /// updates based on the identity of the underlying data. + /// + /// - Parameters: + /// - data: The identified data that the ``ACarousel`` instance uses to + /// create views dynamically. + /// - index: The index of currently active. + /// - spacing: The distance between adjacent subviews, default is 10. + /// - headspace: The width of the exposed side subviews, default is 10 + /// - sidesScaling: The scale of the subviews on both sides, limits 0...1, + /// default is 0.8. + /// - 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`. + /// - content: The view builder that creates views dynamically. + public init(_ data: Data, index: Binding = .constant(0), spacing: CGFloat = 10, headspace: CGFloat = 10, sidesScaling: CGFloat = 0.8, isWrap: Bool = false, autoScroll: ACarouselAutoScroll = .inactive, @ViewBuilder content: @escaping (Data.Element) -> Content) { - if value.translation.width > dragThreshold { - activeItem -= 1 - } - if value.translation.width < -dragThreshold { - activeItem += 1 - } - aState.activeItem = max(0, min(activeItem, aState.data.count - 1)) - } -} - */ - - -// MARK: - App Life Cycle - -#if os(macOS) -import AppKit -typealias Application = NSApplication -#else -import UIKit -typealias Application = UIApplication -#endif - -/// Monitor and receive application life cycles, -/// inactive or active -@available(iOS 13.0, OSX 10.15, *) -struct AppLifeCycleModifier: ViewModifier { - - let active = NotificationCenter.default.publisher(for: Application.didBecomeActiveNotification) - let inactive = NotificationCenter.default.publisher(for: Application.willResignActiveNotification) - - private let action: (Bool) -> () - - init(_ action: @escaping (Bool) -> ()) { - self.action = action + self.viewModel = ACarouselViewModel(data, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll) + self.content = content } - func body(content: Content) -> some View { - content - .onAppear() /// `onReceive` will not work in the Modifier Without `onAppear` - .onReceive(active, perform: { _ in - action(true) - }) - .onReceive(inactive, perform: { _ in - action(false) - }) - } -} - -@available(iOS 13.0, OSX 10.15, *) -extension View { - func onReceiveAppLifeCycle(perform action: @escaping (Bool) -> ()) -> some View { - self.modifier(AppLifeCycleModifier(action)) - } } -// MARK: - Receive Timer -@available(iOS 13.0, OSX 10.15, *) -extension View { - - func onReceive(timer: TimePublisher?, perform action: @escaping (Timer.TimerPublisher.Output) -> Void) -> some View { - Group { - if let timer = timer { - self.onReceive(timer, perform: { value in - action(value) - }) - } else { - self - } - } +@available(iOS 14.0, OSX 11.0, *) +struct ACarousel_LibraryContent: LibraryContentProvider { + let Datas = Array(repeating: _Item(color: .red), count: 3) + @LibraryContentBuilder + var views: [LibraryItem] { + LibraryItem(ACarousel(Datas) { _ in }, title: "ACarousel", category: .control) + LibraryItem(ACarousel(Datas, index: .constant(0), spacing: 10, headspace: 10, sidesScaling: 0.8, isWrap: false, autoScroll: .inactive) { _ in }, title: "ACarousel full parameters", category: .control) } -} -@available(iOS 13.0, OSX 10.15, *) -extension ACarousel { - - func receiveTimer(_ value: Timer.TimerPublisher.Output) { - /// Ignores listen when `isTimerActive` is false. - guard aState.isTimerActive else { - return - } - /// increments of one and compare to the scrolling duration - aState.activeTiming() - if aState.timing < aState.autoScroll.interval { - return - } - - if aState.activeItem == aState.data.count - 1 { - /// `isWrap` is false. - /// Revert to the first view after scrolling to the last view - aState.activeItem = 0 - } else { - /// `isWrap` is true. - /// Incremental, calculation of offset by `offsetChanged(_: proxy:)` - aState.activeItem += 1 - } - aState.resetTiming() - } -} - -/** -// MARK: - Auto Scroll -@available(iOS 13.0, OSX 10.15, *) -extension ACarousel.AutoScroll { - - /// default active - public static var defaultActive: Self { - return .active(5) - } - - /// Is the view auto-scrolling - var isActive: Bool { - switch self { - case .active(let t): return t > 0 - case .inactive : return false - } - } - - /// Duration of automatic scrolling - var interval: TimeInterval { - switch self { - case .active(let t): return t - case .inactive : return 0 - } - } -} - */ - -/** -// MARK: - State -@available(iOS 13.0, OSX 10.15, *) -final private class AState: ObservableObject { - - init(activeItem: Int = 1) { - self.activeItem = activeItem - } - - /// The index of the currently active subview. - @Published var activeItem: Int = 1 - - /// Offset x of the view drag. - @Published var dragOffset: CGFloat = .zero - - - /// Is animation when view is in offset - var animation = false - - /// Define listen to the timer - /// Ignores listen while dragging. and listen again after the drag is over - var isTimerActive = true - - /// Counting of time - /// work when `isTimerActive` is true - /// Toggles the active subviewview and resets if the count is the same as - /// the duration of the auto scroll. Otherwise, increment one - var timing: TimeInterval = 0 - - /// Action at the end of a view drag - func dragEnded() { - dragOffset = .zero - isTimerActive = true - resetTiming() - } - - /// Action at the view dragging - /// - Parameter value: Offset x value of the drag - func dragChanged(_ value: CGFloat) { - dragOffset = value - isTimerActive = false - animation = true - } - - /// reset counting of time - func resetTiming() { - timing = 0 - } - - /// Time increments of one - func activeTiming() { - timing += 1 + struct _Item: Identifiable { + let id = UUID() + let color: Color } } - */ - -//@available(iOS 14.0, OSX 11.0, *) -//struct ACarousel_LibraryContent: LibraryContentProvider { -// let Datas = Array(repeating: _Item(color: .red), count: 3) -// @LibraryContentBuilder -// var views: [LibraryItem] { -// LibraryItem(ACarousel(Datas) { _ in }, title: "ACarousel", category: .control) -// LibraryItem(ACarousel(Datas, spacing: 10, headspace: 10, sidesScaling: 0.8, isWrap: false, autoScroll: .inactive) { _ in }, title: "ACarousel full parameters", category: .control) -// } -// -// struct _Item: Identifiable { -// let id = UUID() -// let color: Color -// } -//} diff --git a/Sources/ACarousel/ACarouselAutoScroll.swift b/Sources/ACarousel/ACarouselAutoScroll.swift new file mode 100644 index 0000000..4a80cb3 --- /dev/null +++ b/Sources/ACarousel/ACarouselAutoScroll.swift @@ -0,0 +1,52 @@ +/** + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +import SwiftUI + +@available(iOS 13.0, OSX 10.15, *) +public enum ACarouselAutoScroll { + case inactive + case active(TimeInterval) +} + + +extension ACarouselAutoScroll { + + /// default active + public static var defaultActive: Self { + return .active(5) + } + + /// Is the view auto-scrolling + var isActive: Bool { + switch self { + case .active(let t): return t > 0 + case .inactive : return false + } + } + + /// Duration of automatic scrolling + var interval: TimeInterval { + switch self { + case .active(let t): return t + case .inactive : return 0 + } + } +} diff --git a/Sources/ACarousel/ACarouselViewModel.swift b/Sources/ACarousel/ACarouselViewModel.swift index c342952..f17ed9f 100644 --- a/Sources/ACarousel/ACarouselViewModel.swift +++ b/Sources/ACarousel/ACarouselViewModel.swift @@ -24,45 +24,9 @@ import Combine @available(iOS 13.0, OSX 10.15, *) class ACarouselViewModel: ObservableObject where Data : RandomAccessCollection, ID : Hashable { - - /// The index of the currently active subview. - @Published var activeItem: Int = 0 { - willSet { - if isWrap { - _activeIndex.wrappedValue = newValue - 1 - } else { - _activeIndex.wrappedValue = newValue - } - } - } - - /// Offset x of the view drag. - @Published var dragOffset: CGFloat = .zero - - /// Is animation when view is in offset - var isAnimationOffset = false - - /// Define listen to the timer - /// Ignores listen while dragging. and listen again after the drag is over - var isTimerActive = true - - /// Counting of time - /// work when `isTimerActive` is true - /// Toggles the active subviewview and resets if the count is the same as - /// the duration of the auto scroll. Otherwise, increment one - var timing: TimeInterval = 0 - - /// size of GeometryProxy - var viewSize: CGSize = .zero { - didSet { - print(viewSize) - } - } - - var timer: TimePublisher? - + /// external index @Binding - private var activeIndex: Int + private var index: Int private let _data: Data private let _dataId: KeyPath @@ -70,16 +34,13 @@ class ACarouselViewModel: ObservableObject where Data : RandomAccessCo private let _headspace: CGFloat private let _isWrap: Bool private let _sidesScaling: CGFloat - private let _autoScroll: AutoScroll - - init(_ data: Data, - id: KeyPath, - activeIndex: Binding = .constant(0), - spacing: CGFloat = 10, - headspace: CGFloat = 10, - sidesScaling: CGFloat = 0.8, - isWrap: Bool = false, - autoScroll: AutoScroll = .inactive) { + private let _autoScroll: ACarouselAutoScroll + + init(_ data: Data, id: KeyPath, index: Binding, spacing: CGFloat, headspace: CGFloat, sidesScaling: CGFloat, isWrap: Bool, autoScroll: ACarouselAutoScroll) { + + guard index.wrappedValue < data.count else { + fatalError("The index should be less than the count of data ") + } self._data = data self._dataId = id @@ -89,37 +50,61 @@ class ACarouselViewModel: ObservableObject where Data : RandomAccessCo self._sidesScaling = sidesScaling self._autoScroll = autoScroll - if data.count > 0 && isWrap { - activeItem = activeIndex.wrappedValue + 1 + if data.count > 1 && isWrap { + activeIndex = index.wrappedValue + 1 } else { - activeItem = activeIndex.wrappedValue + activeIndex = index.wrappedValue } - self._activeIndex = activeIndex - if self.autoScroll.isActive { - timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + self._index = index + } + + + /// The index of the currently active subview. + @Published var activeIndex: Int = 0 { + willSet { + if isWrap { + if newValue > _data.count || newValue == 0 { + return + } + index = newValue - 1 + } else { + index = newValue + } + } + didSet { + changeOffset() } } + + /// Offset x of the view drag. + @Published var dragOffset: CGFloat = .zero + + /// size of GeometryProxy + var viewSize: CGSize = .zero + + + /// Counting of time + /// work when `isTimerActive` is true + /// Toggles the active subviewview and resets if the count is the same as + /// the duration of the auto scroll. Otherwise, increment one + private var timing: TimeInterval = 0 + + /// Define listen to the timer + /// Ignores listen while dragging, and listen again after the drag is over + /// Ignores listen when App will resign active, and listen again when it become active + private var isTimerActive = true + func setTimerActive(_ active: Bool) { + isTimerActive = active + } + } extension ACarouselViewModel where ID == Data.Element.ID, Data.Element : Identifiable { - convenience init(_ data: Data, - spacing: CGFloat = 10, - activeIndex: Binding, - headspace: CGFloat = 10, - sidesScaling: CGFloat = 0.8, - isWrap: Bool = false, - autoScroll: AutoScroll = .inactive) { - self.init(data, - id: \Data.Element.id, - activeIndex: activeIndex, - spacing: spacing, - headspace: headspace, - sidesScaling: sidesScaling, - isWrap: isWrap, - autoScroll: autoScroll) + convenience init(_ data: Data, index: Binding, spacing: CGFloat, headspace: CGFloat, sidesScaling: CGFloat, isWrap: Bool, autoScroll: ACarouselAutoScroll) { + self.init(data, id: \.id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll) } } @@ -139,159 +124,217 @@ extension ACarouselViewModel { return [_data.last!] + _data + [_data.first!] as! Data } - var spacing: CGFloat { - return _spacing + var dataId: KeyPath { + return _dataId } - var headspace: CGFloat { - return _headspace - } - - var sidesScaling: CGFloat { - return max(min(_sidesScaling, 1), 0) - } - - var isWrap: Bool { - return _data.count > 1 ? _isWrap : false - } - - var autoScroll: AutoScroll { - guard _data.count > 1 else { return .inactive } - guard case let .active(t) = _autoScroll else { return _autoScroll } - return t > 0 ? _autoScroll : .defaultActive + var spacing: CGFloat { + return _spacing } var offsetAnimation: Animation? { -// guard isWrap else { -// return .spring() -// } -// -// return (activeItem == 1 || activeItem == data.count - 2) ? .none : .spring() - return isAnimationOffset ? .spring() : .none + guard isWrap else { + return .spring() + } + return isAnimatedOffset ? .spring() : .none } - var defaultPadding: CGFloat { - return (headspace + spacing) + var itemWidth: CGFloat { + viewSize.width - defaultPadding * 2 } - /// with of subview - func itemWidth(_ proxy: GeometryProxy) -> CGFloat { - proxy.size.width - defaultPadding * 2 + var timer: TimePublisher? { + guard autoScroll.isActive else { + return nil + } + return Timer.publish(every: 1, on: .main, in: .common).autoconnect() } - func itemSize(_ proxy: GeometryProxy) -> CGFloat { - itemWidth(proxy) + spacing - } -} - - -extension ACarouselViewModel where ID == Data.Element.ID, Data.Element : Identifiable { - func itemScale(_ item: Data.Element) -> CGFloat { - guard activeItem < data.count else { + /// Defines the scaling based on whether the item is currently active or not. + /// - Parameter item: The incoming item + /// - Returns: scaling + func itemScaling(_ item: Data.Element) -> CGFloat { + guard activeIndex < data.count else { return 0 } - return _dataId == \Data.Element.id ? 1 : sidesScaling + let activeItem = data[activeIndex as! Data.Index] + return activeItem[keyPath: _dataId] == item[keyPath: _dataId] ? 1 : sidesScaling } } +// MARK: - private variable extension ACarouselViewModel { - /// Action at the end of a view drag - func dragEnded() { - dragOffset = .zero - isTimerActive = true - resetTiming() + private var isWrap: Bool { + return _data.count > 1 ? _isWrap : false } - /// Action at the view dragging - /// - Parameter value: Offset x value of the drag - func dragChanged(_ value: CGFloat) { - dragOffset = value - isTimerActive = false - isAnimationOffset = true + private var autoScroll: ACarouselAutoScroll { + guard _data.count > 1 else { return .inactive } + guard case let .active(t) = _autoScroll else { return _autoScroll } + return t > 0 ? _autoScroll : .defaultActive } - /// reset counting of time - func resetTiming() { - timing = 0 + private var defaultPadding: CGFloat { + return (_headspace + spacing) } - /// Time increments of one - func activeTiming() { - timing += 1 + private var itemActualWidth: CGFloat { + itemWidth + spacing + } + + private var sidesScaling: CGFloat { + return max(min(_sidesScaling, 1), 0) + } + + /// Is animated when view is in offset + private var isAnimatedOffset: Bool { + get { UserDefaults.isAnimatedOffset } + set { UserDefaults.isAnimatedOffset = newValue } } } // MARK: - Offset Method extension ACarouselViewModel { - - func offsetValue(_ proxy: GeometryProxy) -> CGFloat { - let activeOffset = CGFloat(activeItem) * itemSize(proxy) - let value = defaultPadding - activeOffset + dragOffset - return value + /// current offset value + var offset: CGFloat { + let activeOffset = CGFloat(activeIndex) * itemActualWidth + return defaultPadding - activeOffset + dragOffset } - func offsetChanged(_ newOffset: CGFloat, proxy: GeometryProxy) { - isAnimationOffset = true + /// change offset when acitveItem changes + private func changeOffset() { + isAnimatedOffset = true guard isWrap else { return } - let minOffset = defaultPadding - let maxOffset = (defaultPadding - CGFloat(data.count - 1) * itemSize(proxy)) - if newOffset == minOffset { + + let minimumOffset = defaultPadding + let maxinumOffset = defaultPadding - CGFloat(data.count - 1) * itemActualWidth + + if offset == minimumOffset { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.activeItem = self.data.count - 2 - self.isAnimationOffset = false + self.activeIndex = self.data.count - 2 + self.isAnimatedOffset = false } - } else if newOffset == maxOffset { + } else if offset == maxinumOffset { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.activeItem = 1 - self.isAnimationOffset = false + self.activeIndex = 1 + self.isAnimatedOffset = false } } } } -// MARK: - Drag Method +// MARK: - Drag Gesture extension ACarouselViewModel { - func dragGesture(_ proxy: GeometryProxy) -> some Gesture { + /// drag gesture of view + var dragGesture: some Gesture { DragGesture() - .onChanged { self.dragChanged($0, proxy: proxy) } - .onEnded { [self] in dragEnded($0, proxy: proxy) } + .onChanged(dragChanged) + .onEnded(dragEnded) } - func dragChanged(_ value: DragGesture.Value, proxy: GeometryProxy) { + private func dragChanged(_ value: DragGesture.Value) { + + isAnimatedOffset = true /// Defines the maximum value of the drag /// Avoid dragging more than the values of multiple subviews at the end of the drag, /// and still only one subview is toggled - var offset: CGFloat = itemSize(proxy) + var offset: CGFloat = itemActualWidth if value.translation.width > 0 { offset = min(offset, value.translation.width) } else { offset = max(-offset, value.translation.width) } - dragChanged(offset) + /// set drag offset + dragOffset = offset + + /// stop active timer + isTimerActive = false } - func dragEnded(_ value: DragGesture.Value, proxy: GeometryProxy) { - dragEnded() + private func dragEnded(_ value: DragGesture.Value) { + /// reset drag offset + dragOffset = .zero + + /// reset timing and restart active timer + resetTiming() + isTimerActive = true /// Defines the drag threshold /// At the end of the drag, if the drag value exceeds the drag threshold, /// the active view will be toggled /// default is one third of subview - let dragThreshold: CGFloat = itemWidth(proxy) / 3 - - var activeItem = self.activeItem + let dragThreshold: CGFloat = itemWidth / 3 + var activeIndex = self.activeIndex if value.translation.width > dragThreshold { - activeItem -= 1 + activeIndex -= 1 } if value.translation.width < -dragThreshold { - activeItem += 1 + activeIndex += 1 + } + self.activeIndex = max(0, min(activeIndex, data.count - 1)) + } +} + +// MARK: - Receive Timer +extension ACarouselViewModel { + + /// timer change + func receiveTimer(_ value: Timer.TimerPublisher.Output) { + /// Ignores listen when `isTimerActive` is false. + guard isTimerActive else { + return + } + /// increments of one and compare to the scrolling duration + /// return when timing less than duration + activeTiming() + timing += 1 + if timing < autoScroll.interval { + return + } + + if activeIndex == data.count - 1 { + /// `isWrap` is false. + /// Revert to the first view after scrolling to the last view + activeIndex = 0 + } else { + /// `isWrap` is true. + /// Incremental, calculation of offset by `offsetChanged(_: proxy:)` + activeIndex += 1 + } + resetTiming() + } + + + /// reset counting of time + private func resetTiming() { + timing = 0 + } + + /// time increments of one + private func activeTiming() { + timing += 1 + } +} + + +private extension UserDefaults { + + private struct Keys { + static let isAnimatedOffset = "isAnimatedOffset" + } + + static var isAnimatedOffset: Bool { + get { + return UserDefaults.standard.bool(forKey: Keys.isAnimatedOffset) + } + set { + UserDefaults.standard.set(newValue, forKey: Keys.isAnimatedOffset) } - self.activeItem = max(0, min(activeItem, data.count - 1)) } } diff --git a/Sources/ACarousel/AppLifeCycleModifier.swift b/Sources/ACarousel/AppLifeCycleModifier.swift new file mode 100644 index 0000000..22c6c5f --- /dev/null +++ b/Sources/ACarousel/AppLifeCycleModifier.swift @@ -0,0 +1,63 @@ +/** + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +import SwiftUI + + +#if os(macOS) +import AppKit +typealias Application = NSApplication +#else +import UIKit +typealias Application = UIApplication +#endif + +/// Monitor and receive application life cycles, +/// inactive or active +@available(iOS 13.0, OSX 10.15, *) +struct AppLifeCycleModifier: ViewModifier { + + let active = NotificationCenter.default.publisher(for: Application.didBecomeActiveNotification) + let inactive = NotificationCenter.default.publisher(for: Application.willResignActiveNotification) + + private let action: (Bool) -> () + + init(_ action: @escaping (Bool) -> ()) { + self.action = action + } + + func body(content: Content) -> some View { + content + .onAppear() /// `onReceive` will not work in the Modifier Without `onAppear` + .onReceive(active, perform: { _ in + action(true) + }) + .onReceive(inactive, perform: { _ in + action(false) + }) + } +} + +@available(iOS 13.0, OSX 10.15, *) +extension View { + func onReceiveAppLifeCycle(perform action: @escaping (Bool) -> ()) -> some View { + self.modifier(AppLifeCycleModifier(action)) + } +} diff --git a/Sources/ACarousel/AutoScroll.swift b/Sources/ACarousel/AutoScroll.swift deleted file mode 100644 index c902448..0000000 --- a/Sources/ACarousel/AutoScroll.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// File.swift -// -// -// Created by 帝云科技 on 2020/12/24. -// - -import SwiftUI - -@available(iOS 13.0, OSX 10.15, *) -public enum AutoScroll { - case inactive - case active(TimeInterval) -} - - -extension AutoScroll { - - /// default active - public static var defaultActive: Self { - return .active(5) - } - - /// Is the view auto-scrolling - var isActive: Bool { - switch self { - case .active(let t): return t > 0 - case .inactive : return false - } - } - - /// Duration of automatic scrolling - var interval: TimeInterval { - switch self { - case .active(let t): return t - case .inactive : return 0 - } - } -} diff --git a/Sources/ACarousel/View+ReceiveTimer.swift b/Sources/ACarousel/View+ReceiveTimer.swift new file mode 100644 index 0000000..e063691 --- /dev/null +++ b/Sources/ACarousel/View+ReceiveTimer.swift @@ -0,0 +1,41 @@ +/** + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +import SwiftUI +import Combine + +@available(iOS 13.0, OSX 10.15, *) +typealias TimePublisher = Publishers.Autoconnect + +@available(iOS 13.0, OSX 10.15, *) +extension View { + + func onReceive(timer: TimePublisher?, perform action: @escaping (Timer.TimerPublisher.Output) -> Void) -> some View { + Group { + if let timer = timer { + self.onReceive(timer, perform: { value in + action(value) + }) + } else { + self + } + } + } +}