├── .mise.toml ├── Development ├── Development │ ├── GlobalCounter.swift │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── DevelopmentApp.swift │ ├── Logger.swift │ ├── BookRerender.swift │ ├── ContentView.swift │ ├── Color.swift │ ├── BookScrollView.swift │ ├── BookVariadicView.swift │ ├── BookPlainCollectionView.swift │ ├── BookUIKitBasedFlow.swift │ ├── BookUIKitBasedCompositional.swift │ └── BookCollectionView.swift └── Development.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ └── xcshareddata │ └── xcschemes │ └── Development.xcscheme ├── Sources ├── DynamicList │ ├── swift_dynamic_list.swift │ ├── Log.swift │ ├── CustomContentConfiguration.swift │ ├── NSDiffableDataSourceSnapshot+Unique.swift │ ├── ContentPagingTrigger.swift │ ├── VersatileCell.swift │ ├── DynamicList.swift │ └── DynamicListView.swift ├── SelectableForEach │ ├── PreviewSupport.swift │ ├── SelectableForEach.swift │ └── SelectionState.swift ├── CollectionView │ ├── CollectionViewLayout.swift │ └── CollectionView.swift ├── StickyHeader │ └── StickyHeader.swift ├── PullingControl │ ├── PullingControl.swift │ └── RefreshControl.swift └── ScrollTracking │ └── ScrollTracking.swift ├── .gitignore ├── Package.resolved ├── Tests └── DynamicListTests │ └── swift_dynamic_listTests.swift ├── Package.swift ├── CLAUDE.md ├── LICENSE └── README.md /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | tuist = "4.18" 3 | -------------------------------------------------------------------------------- /Development/Development/GlobalCounter.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Sources/DynamicList/swift_dynamic_list.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Development/Development/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Development/Development/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Development/Development.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Development/Development/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | /*.xcworkspace 6 | xcuserdata/ 7 | DerivedData/ 8 | .swiftpm/config/registries.json 9 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 10 | .netrc 11 | /Derived 12 | .swiftpm 13 | .claude/settings.local.json 14 | -------------------------------------------------------------------------------- /Development/Development/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Development/Development.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Development/Development/DevelopmentApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DevelopmentApp.swift 3 | // Development 4 | // 5 | // Created by Muukii on 2023/06/09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct DevelopmentApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/DynamicList/Log.swift: -------------------------------------------------------------------------------- 1 | 2 | import os.log 3 | 4 | enum Log { 5 | 6 | static let generic = Logger(OSLog.makeOSLogInDebug { OSLog.init(subsystem: "DynamicList", category: "generic") }) 7 | 8 | } 9 | 10 | extension OSLog { 11 | 12 | @inline(__always) 13 | fileprivate static func makeOSLogInDebug(isEnabled: Bool = true, _ factory: () -> OSLog) -> OSLog { 14 | #if DEBUG 15 | return factory() 16 | #else 17 | return .disabled 18 | #endif 19 | } 20 | 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /Development/Development/Logger.swift: -------------------------------------------------------------------------------- 1 | import os.log 2 | 3 | public enum Log { 4 | 5 | public static let generic = Logger({ 6 | OSLog.makeOSLogInDebug { OSLog.init(subsystem: "app.muukii", category: "generic") } 7 | }()) 8 | } 9 | 10 | 11 | extension OSLog { 12 | 13 | @inline(__always) 14 | fileprivate static func makeOSLogInDebug(isEnabled: Bool = true, _ factory: () -> OSLog) -> OSLog { 15 | #if DEBUG 16 | return factory() 17 | #else 18 | return .disabled 19 | #endif 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/DynamicList/CustomContentConfiguration.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(UIKit) 3 | import UIKit 4 | 5 | public struct CustomContentConfiguration: UIContentConfiguration { 6 | 7 | private let contentViewFactory: @MainActor () -> ContentView 8 | 9 | public init(make: @escaping @MainActor () -> ContentView) { 10 | self.contentViewFactory = make 11 | } 12 | 13 | public func makeContentView() -> UIView & UIContentView { 14 | contentViewFactory() 15 | } 16 | 17 | public func updated(for state: UIConfigurationState) -> CustomContentConfiguration { 18 | 19 | return self 20 | 21 | } 22 | 23 | } 24 | #endif 25 | -------------------------------------------------------------------------------- /Development/Development/BookRerender.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | @testable import SelectableForEach 3 | 4 | private struct _Cell: View { 5 | @Environment(\.collectionView_updateSelection) var update 6 | 7 | var body: some View { 8 | let _ = Self._printChanges() 9 | Text("Cell") 10 | } 11 | } 12 | 13 | #Preview { 14 | 15 | ScrollView { 16 | LazyVStack { 17 | ForEach( 18 | Item.mock(1000) 19 | ) { item in 20 | Control { 21 | print("hit") 22 | } content: { 23 | _Cell() 24 | .environment(\.collectionView_updateSelection,.init(handler: { _ in 25 | print("Update") 26 | })) 27 | } 28 | } 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "727e0a082b397c7ac91af31a279d0adf080aa897660384d87aaea019fde35b16", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-indexed-collection", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/FluidGroup/swift-indexed-collection", 8 | "state" : { 9 | "revision" : "9b17bf06eae73fee93dae9a0fa6de2e33900d9c5", 10 | "version" : "0.2.1" 11 | } 12 | }, 13 | { 14 | "identity" : "swiftui-introspect", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/siteline/swiftui-introspect", 17 | "state" : { 18 | "revision" : "a08b87f96b41055577721a6e397562b21ad52454", 19 | "version" : "26.0.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Tests/DynamicListTests/swift_dynamic_listTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import DynamicList 4 | 5 | final class swift_dynamic_listTests: XCTestCase { 6 | func testDiffable_append_duplicated() throws { 7 | 8 | var snapshot = NSDiffableDataSourceSnapshot() 9 | snapshot.appendSections(["A"]) 10 | 11 | do { 12 | try snapshot.safeAppendItems([0, 0]) 13 | XCTFail() 14 | } catch { 15 | 16 | } 17 | } 18 | 19 | func testDiffable_append_duplicated_2() throws { 20 | 21 | var snapshot = NSDiffableDataSourceSnapshot() 22 | snapshot.appendSections(["A"]) 23 | snapshot.appendItems([0]) 24 | 25 | do { 26 | try snapshot.safeAppendItems([0]) 27 | XCTFail() 28 | } catch { 29 | 30 | } 31 | } 32 | 33 | func testIntersect() throws { 34 | 35 | XCTAssertEqual(Set([1,2]).intersection([3,4]).isEmpty, true) 36 | 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Development/Development/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Development 4 | // 5 | // Created by Muukii on 2023/06/09. 6 | // 7 | 8 | import SwiftUI 9 | @testable import DynamicList 10 | 11 | struct ContentView: View { 12 | var body: some View { 13 | NavigationView { 14 | 15 | List { 16 | NavigationLink("Variadic") { 17 | BookVariadicView() 18 | } 19 | 20 | NavigationLink("UIKit Compositinal") { 21 | BookUIKitBasedCompositional() 22 | } 23 | 24 | NavigationLink("UIKit Flow") { 25 | BookUIKitBasedFlow() 26 | } 27 | 28 | NavigationLink("UICollectionView Lab") { 29 | BookPlainCollectionView() 30 | } 31 | 32 | NavigationLink("CollectionView") { 33 | BookCollectionViewSingleSection() 34 | } 35 | 36 | NavigationLink("ScrollView") { 37 | OnAdditionalLoading_Previews() 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | struct ContentView_Previews: PreviewProvider { 45 | static var previews: some View { 46 | ContentView() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Development/Development/Color.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | 5 | static var vibrantBlue: Color { 6 | // #0600FF 7 | Color(.displayP3, red: 0.023529411764705882, green: 0.0, blue: 1.0, opacity: 1) 8 | } 9 | 10 | static var vibrantOrange: Color { 11 | // #FF7A00 12 | Color(.displayP3, red: 1.0, green: 0.47843137254901963, blue: 0.0, opacity: 1) 13 | } 14 | 15 | static var vibrantRed: Color { 16 | // #FF3D00 17 | Color(.displayP3, red: 1.0, green: 0.23921568627450981, blue: 0.0, opacity: 1) 18 | } 19 | 20 | static var vibrantPurple: Color { 21 | // #8F00FF 22 | Color(.displayP3, red: 0.5607843137254902, green: 0.0, blue: 1.0, opacity: 1) 23 | } 24 | 25 | static var vibrantYellow: Color { 26 | // #FFA800 27 | Color(.displayP3, red: 1.0, green: 0.6588235294117647, blue: 0.0, opacity: 1) 28 | } 29 | 30 | static var vibrantPink: Color { 31 | // #ED0047 32 | Color(.displayP3, red: 0.9294117647058824, green: 0.0, blue: 0.2784313725490196, opacity: 1) 33 | } 34 | 35 | static var vibrantGreen: Color { 36 | // #00E124 37 | Color(.displayP3, red: 0.0, green: 0.8823529411764706, blue: 0.1411764705882353, opacity: 1) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SelectableForEach/PreviewSupport.swift: -------------------------------------------------------------------------------- 1 | 2 | #if DEBUG 3 | 4 | import SwiftUI 5 | 6 | struct Item: Identifiable, Hashable { 7 | var id: Int 8 | var title: String 9 | 10 | static func mock(_ count: Int = 1000) -> [Item] { 11 | return (0.. Void 51 | 52 | var body: some View { 53 | let _ = print("Render \(name)") 54 | Text(name) 55 | .font(.title) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/DynamicList/NSDiffableDataSourceSnapshot+Unique.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(UIKit) 3 | import UIKit 4 | #endif 5 | #if canImport(AppKit) 6 | import AppKit 7 | #endif 8 | 9 | public enum DiffableDataSourceError: Error, Sendable { 10 | case duplicatedSectionIdentifiers(debugDescription: String) 11 | case duplicatedItemIdentifiers(debugDescription: String) 12 | } 13 | 14 | extension NSDiffableDataSourceSnapshot { 15 | 16 | public mutating func safeAppendSections( 17 | _ sections: [SectionIdentifierType] 18 | ) throws { 19 | 20 | var sectionSet: Set = [] 21 | 22 | for section in sections { 23 | let (inserted, _) = sectionSet.insert(section) 24 | guard inserted else { 25 | throw DiffableDataSourceError.duplicatedSectionIdentifiers(debugDescription: String(describing: [section])) 26 | } 27 | } 28 | 29 | 30 | var set = Set(sectionIdentifiers) 31 | set.formIntersection(sections) 32 | 33 | if set.isEmpty { 34 | appendSections(sections) 35 | } else { 36 | throw DiffableDataSourceError.duplicatedSectionIdentifiers(debugDescription: String(describing: set)) 37 | } 38 | 39 | } 40 | 41 | public mutating func safeAppendItems( 42 | _ items: [ItemIdentifierType], 43 | intoSection sectionIdentifier: SectionIdentifierType? = nil 44 | ) throws { 45 | 46 | var itemSet: Set = [] 47 | 48 | for item in items { 49 | let (inserted, _) = itemSet.insert(item) 50 | guard inserted else { 51 | throw DiffableDataSourceError.duplicatedItemIdentifiers(debugDescription: String(describing: [item])) 52 | } 53 | } 54 | 55 | var set = Set(itemIdentifiers) 56 | set.formIntersection(items) 57 | 58 | if set.isEmpty { 59 | appendItems(items, toSection: sectionIdentifier) 60 | } else { 61 | throw DiffableDataSourceError.duplicatedItemIdentifiers(debugDescription: String(describing: set)) 62 | } 63 | 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Development/Development.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "9b99766a2978feffac3b2f465fb22ea92972e89ef6cdd3af42b3c49e7bdfa3d0", 3 | "pins" : [ 4 | { 5 | "identity" : "compositionkit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/FluidGroup/CompositionKit.git", 8 | "state" : { 9 | "revision" : "c7b2bbb2eed8318ad886f2815ac6cb275318db5d", 10 | "version" : "0.4.4" 11 | } 12 | }, 13 | { 14 | "identity" : "descriptors", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/muukii/Descriptors.git", 17 | "state" : { 18 | "revision" : "f41ce2605a76c5d378fe8c5e8c5c98b544dfd108", 19 | "version" : "0.2.3" 20 | } 21 | }, 22 | { 23 | "identity" : "mondrianlayout", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/muukii/MondrianLayout.git", 26 | "state" : { 27 | "revision" : "5f00b13984fe08316fc5b5be06e2f41c14a3befa", 28 | "version" : "0.10.0" 29 | } 30 | }, 31 | { 32 | "identity" : "nuke", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/kean/Nuke.git", 35 | "state" : { 36 | "revision" : "f4d9b95788679d0654c032961f73e7e9c16ca6b4", 37 | "version" : "12.1.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swiftui-async-multiplex-image", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/FluidGroup/swiftui-async-multiplex-image", 44 | "state" : { 45 | "revision" : "21f295509631e4e471b93b95c30423314bade9a1", 46 | "version" : "0.3.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swiftui-gesture-velocity", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/FluidGroup/swiftui-gesture-velocity", 53 | "state" : { 54 | "revision" : "9c83f8995f9e5efc29db2fca4b9ff058283f1603", 55 | "version" : "1.0.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swiftui-support", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/FluidGroup/swiftui-support", 62 | "state" : { 63 | "revision" : "8ef53190c33bd345e7a95ef504dafe0f85ad9c4d", 64 | "version" : "0.4.1" 65 | } 66 | } 67 | ], 68 | "version" : 3 69 | } 70 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swiftui-list-support", 8 | platforms: [ 9 | .macOS(.v15), 10 | .iOS(.v17) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "DynamicList", 16 | targets: ["DynamicList"] 17 | ), 18 | .library( 19 | name: "CollectionView", 20 | targets: ["CollectionView"] 21 | ), 22 | .library( 23 | name: "ScrollTracking", 24 | targets: ["ScrollTracking"] 25 | ), 26 | .library( 27 | name: "StickyHeader", 28 | targets: ["StickyHeader"] 29 | ), 30 | .library( 31 | name: "PullingControl", 32 | targets: ["PullingControl"] 33 | ), 34 | .library( 35 | name: "SelectableForEach", 36 | targets: ["SelectableForEach"] 37 | ), 38 | ], 39 | dependencies: [ 40 | .package(url: "https://github.com/FluidGroup/swift-indexed-collection", from: "0.2.1"), 41 | .package(url: "https://github.com/siteline/swiftui-introspect", from: "26.0.0"), 42 | ], 43 | targets: [ 44 | // Targets are the basic building blocks of a package, defining a module or a test suite. 45 | // Targets can depend on other targets in this package and products from dependencies. 46 | .target( 47 | name: "DynamicList", 48 | dependencies: [ 49 | ] 50 | ), 51 | .target( 52 | name: "CollectionView", 53 | dependencies: [ 54 | "ScrollTracking", 55 | ] 56 | ), 57 | .target( 58 | name: "ScrollTracking", 59 | dependencies: [ 60 | .product(name: "SwiftUIIntrospect", package: "swiftui-introspect") 61 | ] 62 | ), 63 | .target( 64 | name: "StickyHeader", 65 | dependencies: [ 66 | ] 67 | ), 68 | .target( 69 | name: "PullingControl", 70 | dependencies: [ 71 | ] 72 | ), 73 | .target( 74 | name: "SelectableForEach", 75 | dependencies: [ 76 | .product(name: "IndexedCollection", package: "swift-indexed-collection"), 77 | ] 78 | ), 79 | .testTarget( 80 | name: "DynamicListTests", 81 | dependencies: ["DynamicList"] 82 | ), 83 | ], 84 | swiftLanguageModes: [.v6] 85 | ) 86 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Build Commands 6 | 7 | ```bash 8 | # Build the Swift package 9 | swift build 10 | 11 | # Build the development app (for testing features) 12 | xcodebuild -project Development/Development.xcodeproj -scheme Development build 13 | ``` 14 | 15 | ## Test Commands 16 | 17 | ```bash 18 | # Run all tests 19 | swift test 20 | 21 | # Run tests using Xcode 22 | xcodebuild test -project Development/Development.xcodeproj -scheme Development 23 | ``` 24 | 25 | ## Architecture Overview 26 | 27 | This is a Swift Package providing UIKit-based collection view components with SwiftUI integration, organized into four main modules: 28 | 29 | ### DynamicList (Main module) 30 | - **DynamicListView**: Core UIKit-based collection view with NSDiffableDataSource 31 | - **VersatileCell**: Flexible cell implementation supporting SwiftUI content via hosting 32 | - **HostingConfiguration**: Manages SwiftUI view hosting in UIKit cells 33 | - Supports incremental content loading with `ContentPagingTrigger` 34 | 35 | ### CollectionView 36 | - **CollectionView**: Pure SwiftUI implementation using UICollectionView under the hood 37 | - **SelectableForEach**: Provides selection support (single/multiple) 38 | - **CollectionViewLayout**: Configurable layouts (list, grid, compositional) 39 | 40 | ### ScrollTracking 41 | - Provides scroll position tracking functionality for SwiftUI views 42 | 43 | ### StickyHeader 44 | - Implements sticky header behavior for scroll views 45 | 46 | ## Key Implementation Patterns 47 | 48 | ### Cell Provider Pattern 49 | The library uses a cell provider pattern where cells are configured through closures: 50 | 51 | ```swift 52 | list.setUp( 53 | cellProvider: { context in 54 | // context.cell returns a UICollectionViewCell hosting SwiftUI content 55 | context.cell { state in 56 | // SwiftUI view content 57 | } 58 | } 59 | ) 60 | ``` 61 | 62 | ### Diffable Data Source 63 | Uses NSDiffableDataSourceSnapshot for efficient updates with custom extensions for safety: 64 | - `NSDiffableDataSourceSnapshot+Unique.swift` provides safe operations 65 | 66 | ### SwiftUI Integration 67 | - UIKit cells host SwiftUI views through `HostingConfiguration` 68 | - Supports state preservation and updates 69 | - Pre-rendering capabilities via swift-with-prerender dependency 70 | 71 | ## Platform Requirements 72 | - iOS 16+ 73 | - macOS 15+ 74 | - Swift 6.0 language mode 75 | 76 | ## Development App 77 | The Development/ directory contains a comprehensive example app demonstrating: 78 | - Various collection view layouts 79 | - Selection handling 80 | - Performance testing 81 | - Different implementation approaches -------------------------------------------------------------------------------- /Development/Development.xcodeproj/xcshareddata/xcschemes/Development.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Sources/SelectableForEach/SelectableForEach.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import IndexedCollection 3 | 4 | /** 5 | A structure that computes views on demand from an underlying collection of identified data with selection funtion. 6 | The contents that produced by ForEach have environment values that indicate the selection state. 7 | 8 | ``` 9 | @Environment(\.selectableForEach_isSelected) var isSelected: Bool 10 | @Environment(\.selectableForEach_updateSelection) var updateSelection: (Bool) -> Void 11 | ``` 12 | */ 13 | public struct SelectableForEach< 14 | Data: RandomAccessCollection, 15 | Cell: View, 16 | Selection: SelectionState 17 | >: View where Data.Element: Identifiable { 18 | 19 | public let data: Data 20 | public let selection: Selection 21 | public let selectionIdentifier: KeyPath 22 | private let cell: (Data.Index, Data.Element) -> Cell 23 | 24 | public init( 25 | data: Data, 26 | selection: Selection, 27 | selectionIdentifier: KeyPath, 28 | @ViewBuilder cell: @escaping (Data.Index, Data.Element) -> Cell 29 | ) { 30 | self.data = data 31 | self.cell = cell 32 | self.selectionIdentifier = selectionIdentifier 33 | self.selection = selection 34 | } 35 | 36 | public init( 37 | data: Data, 38 | selection: Selection, 39 | @ViewBuilder cell: @escaping (Data.Index, Data.Element) -> Cell 40 | ) where Selection.Identifier == Data.Element.ID { 41 | self.data = data 42 | self.cell = cell 43 | self.selectionIdentifier = \.id 44 | self.selection = selection 45 | } 46 | 47 | public var body: some View { 48 | ForEach(IndexedCollection(data)) { element in 49 | 50 | selection.applyEnvironments( 51 | for: cell(element.index, element.value), 52 | identifier: element.value[keyPath: selectionIdentifier] 53 | ) 54 | } 55 | } 56 | 57 | } 58 | 59 | extension EnvironmentValues { 60 | /** 61 | A boolean value that indicates whether the cell is selected. 62 | Provided by the ``SelectableForEach`` view. 63 | */ 64 | @available(*, deprecated, renamed: "selectableForEach_isSelected") 65 | @Entry public var collectionView_isSelected: Bool = false 66 | 67 | public var selectableForEach_isSelected: Bool { 68 | get { self.collectionView_isSelected } 69 | set { self.collectionView_isSelected = newValue } 70 | } 71 | } 72 | 73 | extension EnvironmentValues { 74 | /** 75 | A closure that updates the selection state of the cell. 76 | Provided by the ``SelectableForEach`` view. 77 | */ 78 | @available(*, deprecated, renamed: "selectableForEach_updateSelection") 79 | @Entry public var collectionView_updateSelection: UpdateSelectionAction = .init { _ in } 80 | 81 | public var selectableForEach_updateSelection: UpdateSelectionAction { 82 | get { self.collectionView_updateSelection } 83 | set { self.collectionView_updateSelection = newValue } 84 | } 85 | } 86 | 87 | public struct UpdateSelectionAction { 88 | 89 | private let handler: (Bool) -> Void 90 | 91 | nonisolated public init(handler: @escaping (Bool) -> Void) { 92 | self.handler = handler 93 | } 94 | 95 | public func callAsFunction(_ value: Bool) { 96 | handler(value) 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Development/Development/BookVariadicView.swift: -------------------------------------------------------------------------------- 1 | import AsyncMultiplexImage 2 | import AsyncMultiplexImage_Nuke 3 | import DynamicList 4 | import SwiftUI 5 | 6 | #if DEBUG 7 | struct BookVariadicView: View, PreviewProvider { 8 | var body: some View { 9 | 10 | List { 11 | 12 | NavigationLink { 13 | NativeContent() 14 | .navigationTitle("Native") 15 | } label: { 16 | Text("Native") 17 | } 18 | 19 | } 20 | 21 | } 22 | 23 | static var previews: some View { 24 | NavigationView { 25 | List { 26 | 27 | NavigationLink { 28 | NativeContent() 29 | .navigationTitle("Native") 30 | } label: { 31 | Text("Native") 32 | } 33 | 34 | } 35 | } 36 | } 37 | 38 | private struct NativeContent: View { 39 | 40 | @State var items: [Message] = MockData.randomMessages(count: 2000) 41 | 42 | var body: some View { 43 | VStack { 44 | ScrollView { 45 | LazyVStack(spacing: 0) { 46 | ForEach( 47 | items, 48 | content: { 49 | ComplexCell(message: $0) 50 | } 51 | ) 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | static let url = URL( 59 | string: 60 | "https://images.unsplash.com/photo-1686726754283-3cf793dec0e6?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80" 61 | )! 62 | 63 | struct ComplexCell: View { 64 | 65 | let message: Message 66 | 67 | @State var count = 0 68 | 69 | var body: some View { 70 | 71 | HStack { 72 | Text(count.description) 73 | Text(message.text) 74 | 75 | AsyncMultiplexImage( 76 | multiplexImage: .init(identifier: "1", urls: [BookVariadicView.url]), 77 | downloader: AsyncMultiplexImageNukeDownloader(pipeline: .shared, debugDelay: 0), 78 | content: { phase in 79 | switch phase { 80 | case .empty: 81 | Color.gray 82 | case .success(let image): 83 | image 84 | .resizable() 85 | .scaledToFill() 86 | case .failure: 87 | Color.red 88 | case .progress: 89 | Color.blue 90 | } 91 | } 92 | ) 93 | .frame(width: 50, height: 50) 94 | 95 | } 96 | .padding(16) 97 | .background( 98 | RoundedRectangle(cornerRadius: 8, style: .continuous) 99 | .fill(Color.yellow) 100 | ) 101 | .padding(8) 102 | } 103 | } 104 | 105 | struct Message: Identifiable { 106 | let id = UUID() 107 | var text: String 108 | } 109 | 110 | struct MockData { 111 | static let cannedText = [ 112 | "Quisque maximus non est non condimentum.", 113 | "Praesent sit amet condimentum lacus, vel vehicula tellus. Cras non dolor vel nulla accumsan mollis.", 114 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus risus libero, laoreet eget cursus vitae, malesuada quis magna. Sed tristique pharetra ultrices. Suspendisse vitae est quis leo auctor commodo eget vitae tortor. Sed convallis rutrum luctus. Fusce in nibh suscipit, venenatis est fringilla, sollicitudin mi.", 115 | "Aliquam euismod, tortor ut venenatis mattis, est neque rutrum massa, vitae laoreet nibh ex eu arcu. Curabitur ut augue in sem aliquam ultrices. Integer mollis mattis eros eget vulputate.", 116 | "Nam cursus semper lacinia. Nullam pretium massa auctor, vehicula augue ac, bibendum lorem.", 117 | "Hi", 118 | ] 119 | 120 | static func makeMessage() -> String { 121 | return cannedText.randomElement()! 122 | } 123 | 124 | static func randomMessages(count: Int) -> [Message] { 125 | var messages = [Message]() 126 | 127 | for _ in 0.. { 9 | 10 | associatedtype Identifier: Hashable 11 | 12 | /// Returns whether the item is selected or not 13 | func isSelected(for identifier: Identifier) -> Bool 14 | 15 | /// Returns whether the item is enabled to be selected or not 16 | func isEnabled(for identifier: Identifier) -> Bool 17 | 18 | /// Update the selection state 19 | func update(isSelected: Bool, for identifier: Identifier) 20 | } 21 | 22 | extension SelectionState { 23 | 24 | public func applyEnvironments(for body: Body, identifier: Identifier) -> some View { 25 | 26 | let isSelected: Bool = isSelected(for: identifier) 27 | let isDisabled: Bool = !isEnabled(for: identifier) 28 | 29 | return body 30 | .disabled(isDisabled) 31 | .environment(\.collectionView_isSelected, isSelected) 32 | .environment( 33 | \.collectionView_updateSelection, 34 | .init(handler: { isSelected in 35 | self.update(isSelected: isSelected, for: identifier) 36 | }) 37 | ) 38 | } 39 | 40 | } 41 | 42 | extension SelectionState { 43 | 44 | public static func single( 45 | selected: Identifier?, 46 | onChange: @escaping (_ selected: Identifier?) -> Void 47 | ) -> Self where Self == SelectionStateContainers.Single { 48 | .init( 49 | selected: selected, 50 | onChange: onChange 51 | ) 52 | } 53 | 54 | public static func multiple( 55 | selected: some Collection, 56 | canSelectMore: Bool, 57 | onChange: @escaping (_ selected: Identifier, _ selection: SelectAction) -> Void 58 | ) -> Self where Self == SelectionStateContainers.Multiple { 59 | .init( 60 | selected: selected, 61 | canSelectMore: canSelectMore, 62 | onChange: onChange 63 | ) 64 | } 65 | 66 | public static func disabled() -> Self where Self == SelectionStateContainers.Disabled { 67 | .init() 68 | } 69 | 70 | } 71 | 72 | /** 73 | A namespace for selection state containers. 74 | */ 75 | public enum SelectionStateContainers { 76 | 77 | public struct Disabled: SelectionState { 78 | 79 | public init() { 80 | 81 | } 82 | 83 | public func isSelected(for id: Identifier) -> Bool { 84 | false 85 | } 86 | 87 | public func isEnabled(for id: Identifier) -> Bool { 88 | true 89 | } 90 | 91 | public func update(isSelected: Bool, for identifier: Identifier) { 92 | 93 | } 94 | } 95 | 96 | public struct Single: SelectionState { 97 | 98 | public let selected: Identifier? 99 | 100 | private let onChange: (_ selected: Identifier?) -> Void 101 | 102 | public init( 103 | selected: Identifier?, 104 | onChange: @escaping (_ selected: Identifier?) -> Void 105 | ) { 106 | self.selected = selected 107 | self.onChange = onChange 108 | } 109 | 110 | public func isSelected(for id: Identifier) -> Bool { 111 | self.selected == id 112 | } 113 | 114 | public func isEnabled(for id: Identifier) -> Bool { 115 | return true 116 | } 117 | 118 | public func update(isSelected: Bool, for item: Identifier) { 119 | if isSelected { 120 | onChange(item) 121 | } else { 122 | onChange(nil) 123 | } 124 | } 125 | 126 | } 127 | 128 | public struct Multiple: SelectionState { 129 | 130 | public let selected: any Collection 131 | 132 | public let canSelectMore: Bool 133 | 134 | private let onChange: (_ selected: Identifier, _ action: SelectAction) -> Void 135 | 136 | public init( 137 | selected: any Collection, 138 | canSelectMore: Bool, 139 | onChange: @escaping (_ selected: Identifier, _ action: SelectAction) -> Void 140 | ) { 141 | self.selected = selected 142 | self.canSelectMore = canSelectMore 143 | self.onChange = onChange 144 | } 145 | 146 | public func isSelected(for id: Identifier) -> Bool { 147 | selected.contains(id) 148 | } 149 | 150 | public func isEnabled(for id: Identifier) -> Bool { 151 | if isSelected(for: id) { 152 | return true 153 | } 154 | return canSelectMore 155 | } 156 | 157 | public func update(isSelected: Bool, for identifier: Identifier) { 158 | if isSelected { 159 | onChange(identifier, .selected) 160 | } else { 161 | onChange(identifier, .deselected) 162 | } 163 | } 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionViewLayout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public enum CollectionViewListDirection { 4 | case vertical 5 | case horizontal 6 | } 7 | 8 | /// A protocol that makes laid out contents of the collection view 9 | public protocol CollectionViewLayoutType: ViewModifier { 10 | 11 | } 12 | 13 | public enum CollectionViewLayouts { 14 | 15 | public struct PlatformList: CollectionViewLayoutType { 16 | 17 | public init() { 18 | } 19 | 20 | public func body(content: Content) -> some View { 21 | SwiftUI.List { 22 | content 23 | } 24 | } 25 | } 26 | 27 | public struct PlatformListVanilla: CollectionViewLayoutType { 28 | 29 | public init() { 30 | } 31 | 32 | public func body(content: Content) -> some View { 33 | SwiftUI.List { 34 | content 35 | .listSectionSeparator(.hidden) 36 | .listRowSeparator(.hidden) 37 | .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) 38 | } 39 | .listStyle(.plain) 40 | } 41 | } 42 | 43 | public struct List: CollectionViewLayoutType { 44 | 45 | public let direction: CollectionViewListDirection 46 | 47 | public var showsIndicators: Bool = false 48 | 49 | public var contentPadding: EdgeInsets 50 | 51 | 52 | 53 | public init( 54 | direction: CollectionViewListDirection, 55 | contentPadding: EdgeInsets = .init() 56 | ) { 57 | self.direction = direction 58 | self.contentPadding = contentPadding 59 | } 60 | 61 | 62 | public consuming func contentPadding(_ contentPadding: EdgeInsets) -> Self { 63 | 64 | self.contentPadding = contentPadding 65 | 66 | return self 67 | } 68 | 69 | public consuming func showsIndicators(_ showsIndicators: Bool) -> Self { 70 | 71 | self.showsIndicators = showsIndicators 72 | 73 | return self 74 | } 75 | 76 | public func body(content: Content) -> some View { 77 | switch direction { 78 | case .vertical: 79 | 80 | ScrollView(.vertical, showsIndicators: showsIndicators) { 81 | 82 | LazyVStack { 83 | content 84 | } 85 | .padding(contentPadding) 86 | } 87 | 88 | case .horizontal: 89 | 90 | ScrollView(.horizontal, showsIndicators: showsIndicators) { 91 | 92 | LazyHStack { 93 | content 94 | } 95 | .padding(contentPadding) 96 | } 97 | 98 | } 99 | } 100 | 101 | } 102 | 103 | public struct Grid: CollectionViewLayoutType { 104 | 105 | public let gridItems: [GridItem] 106 | 107 | public let direction: CollectionViewListDirection 108 | 109 | public var showsIndicators: Bool = false 110 | 111 | public var contentPadding: EdgeInsets 112 | 113 | public var spacing: CGFloat? 114 | 115 | public init( 116 | gridItems: [GridItem], 117 | direction: CollectionViewListDirection, 118 | spacing: CGFloat? = nil, 119 | contentPadding: EdgeInsets = .init() 120 | ) { 121 | self.direction = direction 122 | self.contentPadding = contentPadding 123 | self.gridItems = gridItems 124 | self.spacing = spacing 125 | self.contentPadding = contentPadding 126 | } 127 | 128 | public consuming func contentPadding(_ contentPadding: EdgeInsets) -> Self { 129 | 130 | self.contentPadding = contentPadding 131 | 132 | return self 133 | } 134 | 135 | public consuming func showsIndicators(_ showsIndicators: Bool) -> Self { 136 | 137 | self.showsIndicators = showsIndicators 138 | 139 | return self 140 | } 141 | 142 | public func body(content: Content) -> some View { 143 | switch direction { 144 | case .vertical: 145 | 146 | ScrollView(.vertical, showsIndicators: showsIndicators) { 147 | LazyVGrid( 148 | columns: gridItems, 149 | spacing: spacing 150 | ) { 151 | content 152 | } 153 | .padding(contentPadding) 154 | } 155 | 156 | case .horizontal: 157 | 158 | ScrollView(.horizontal, showsIndicators: showsIndicators) { 159 | LazyHGrid( 160 | rows: gridItems, 161 | spacing: spacing 162 | ) { 163 | content 164 | } 165 | .padding(contentPadding) 166 | } 167 | 168 | } 169 | } 170 | 171 | } 172 | 173 | } 174 | 175 | extension CollectionViewLayoutType where Self == CollectionViewLayouts.List { 176 | 177 | public static var list: Self { 178 | CollectionViewLayouts.List( 179 | direction: .vertical 180 | ) 181 | } 182 | 183 | } 184 | 185 | extension CollectionViewLayoutType { 186 | 187 | public static func grid( 188 | gridItems: [GridItem], 189 | direction: CollectionViewListDirection, 190 | spacing: CGFloat? = nil 191 | ) -> Self where Self == CollectionViewLayouts.Grid { 192 | CollectionViewLayouts.Grid( 193 | gridItems: gridItems, 194 | direction: direction, 195 | spacing: spacing 196 | ) 197 | } 198 | 199 | } 200 | -------------------------------------------------------------------------------- /Sources/DynamicList/ContentPagingTrigger.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import UIKit 3 | 4 | /// - Provides a timing to trigger batch fetching (adding more items) 5 | /// - According to scrolling 6 | /// - Multiple edge supported - up, down. 7 | /// 8 | /// Observing the target scroll view's content-offset. 9 | /// 10 | /// - Author: Muukii 11 | @available(iOS 13, *) 12 | @MainActor 13 | final class ContentPagingTrigger { 14 | 15 | public enum TrackingScrollDirection { 16 | case up 17 | case down 18 | case right 19 | 20 | func isMatchDirection(oldContentOffset: CGPoint?, newContentOffset: CGPoint) -> Bool { 21 | guard let oldContentOffset = oldContentOffset else { 22 | return false 23 | } 24 | 25 | switch self { 26 | case .up: 27 | return newContentOffset.y < oldContentOffset.y 28 | case .down: 29 | return newContentOffset.y > oldContentOffset.y 30 | case .right: 31 | return newContentOffset.x > oldContentOffset.x 32 | } 33 | 34 | } 35 | } 36 | 37 | // MARK: - Properties 38 | 39 | public var onBatchFetch: (@MainActor () async -> Void)? 40 | 41 | private var currentTask: Task? 42 | 43 | public var isEnabled: Bool = true 44 | 45 | private var oldContentOffset: CGPoint? 46 | 47 | public let trackingScrollDirection: TrackingScrollDirection 48 | 49 | public let leadingScreensForBatching: CGFloat 50 | 51 | private var offsetObservation: NSKeyValueObservation? 52 | private var contentSizeObservation: NSKeyValueObservation? 53 | 54 | // MARK: - Initializers 55 | 56 | public init( 57 | scrollView: UIScrollView, 58 | trackingScrollDirection: TrackingScrollDirection, 59 | leadingScreensForBatching: CGFloat = 2 60 | ) { 61 | self.leadingScreensForBatching = leadingScreensForBatching 62 | self.trackingScrollDirection = trackingScrollDirection 63 | 64 | offsetObservation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] scrollView, _ in 65 | guard let `self` = self else { return } 66 | MainActor.assumeIsolated { 67 | self.didScroll(scrollView: scrollView) 68 | } 69 | } 70 | 71 | contentSizeObservation = scrollView.observe(\.contentSize, options: [.initial, .new]) { scrollView, _ in 72 | // print(scrollView.contentSize) 73 | } 74 | } 75 | 76 | deinit { 77 | offsetObservation?.invalidate() 78 | contentSizeObservation?.invalidate() 79 | } 80 | 81 | // MARK: - Functions 82 | 83 | public func didScroll(scrollView: UIScrollView) { 84 | 85 | guard onBatchFetch != nil else { 86 | return 87 | } 88 | 89 | let bounds = scrollView.bounds 90 | let contentSize = scrollView.contentSize 91 | let targetOffset = scrollView.contentOffset 92 | let leadingScreens = leadingScreensForBatching 93 | 94 | guard currentTask == nil else { 95 | return 96 | } 97 | 98 | guard 99 | trackingScrollDirection.isMatchDirection( 100 | oldContentOffset: oldContentOffset, 101 | newContentOffset: targetOffset 102 | ) 103 | else { 104 | oldContentOffset = scrollView.contentOffset 105 | return 106 | } 107 | 108 | oldContentOffset = scrollView.contentOffset 109 | 110 | guard leadingScreens > 0 || bounds != .zero else { 111 | return 112 | } 113 | 114 | let viewLength = bounds.size.height 115 | let offset = targetOffset.y 116 | let contentLength = contentSize.height 117 | 118 | switch trackingScrollDirection { 119 | case .up: 120 | 121 | // target offset will always be 0 if the content size is smaller than the viewport 122 | let hasSmallContent = offset == 0.0 && contentLength < viewLength 123 | 124 | let triggerDistance = viewLength * leadingScreens 125 | let remainingDistance = offset 126 | 127 | if hasSmallContent || remainingDistance <= triggerDistance { 128 | 129 | trigger() 130 | } 131 | case .down: 132 | // target offset will always be 0 if the content size is smaller than the viewport 133 | let hasSmallContent = offset == 0.0 && contentLength < viewLength 134 | 135 | let triggerDistance = viewLength * leadingScreens 136 | let remainingDistance = contentLength - viewLength - offset 137 | 138 | if hasSmallContent || remainingDistance <= triggerDistance { 139 | 140 | trigger() 141 | } 142 | case .right: 143 | 144 | let viewWidth = bounds.size.width 145 | let offsetX = targetOffset.x 146 | let contentWidth = contentSize.width 147 | 148 | let hasSmallContent = offsetX == 0.0 && contentWidth < viewWidth 149 | 150 | let triggerDistance = viewWidth * leadingScreens 151 | let remainingDistance = contentWidth - viewWidth - offsetX 152 | 153 | if hasSmallContent || remainingDistance <= triggerDistance { 154 | 155 | trigger() 156 | } 157 | } 158 | } 159 | 160 | private func trigger() { 161 | 162 | guard isEnabled else { return } 163 | triggerManually() 164 | } 165 | 166 | public func triggerManually() { 167 | 168 | guard let onBatchFetch else { return } 169 | guard currentTask == nil else { return } 170 | 171 | let task = Task { 172 | await onBatchFetch() 173 | 174 | self.currentTask = nil 175 | } 176 | 177 | currentTask = task 178 | } 179 | } 180 | #endif 181 | -------------------------------------------------------------------------------- /Development/Development/BookPlainCollectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import DynamicList 4 | import MondrianLayout 5 | 6 | struct BookPlainCollectionView: View, PreviewProvider { 7 | var body: some View { 8 | ContentView() 9 | } 10 | 11 | static var previews: some View { 12 | Self() 13 | } 14 | 15 | private struct ContentView: View { 16 | 17 | var body: some View { 18 | _View() 19 | } 20 | } 21 | 22 | private struct _View: UIViewRepresentable { 23 | 24 | func makeUIView(context: Context) -> ContainerView { 25 | ContainerView(frame: .zero) 26 | } 27 | 28 | func updateUIView(_ uiView: ContainerView, context: Context) { 29 | 30 | } 31 | } 32 | 33 | private final class ContainerView: UIView, UICollectionViewDataSource { 34 | 35 | private final class CustomCellContent: UIView, UIContentView { 36 | 37 | private let mark: UIView = .init() 38 | private let mark2: UIView = .init() 39 | private let label: UILabel = .init() 40 | 41 | var configuration: UIContentConfiguration { 42 | didSet { 43 | print("update configuration \(configuration)") 44 | update(with: configuration as! BookPlainCollectionView.ContainerView.CustomCellConfiguration) 45 | } 46 | } 47 | 48 | init(configuration: CustomCellConfiguration) { 49 | 50 | print(#function) 51 | 52 | self.configuration = configuration 53 | super.init(frame: .zero) 54 | 55 | mark.backgroundColor = UIColor.systemPurple 56 | mark.backgroundColor = UIColor.systemRed 57 | 58 | Mondrian.buildSubviews(on: self) { 59 | 60 | HStackBlock { 61 | VStackBlock { 62 | mark 63 | .viewBlock.size(width: 20, height: 20) 64 | mark2 65 | .viewBlock.size(width: 20, height: 20) 66 | } 67 | VStackBlock { 68 | label 69 | .viewBlock.padding(20) 70 | } 71 | } 72 | } 73 | 74 | update(with: configuration) 75 | } 76 | 77 | private func update(with configuration: CustomCellConfiguration) { 78 | 79 | label.text = configuration.text 80 | mark.isHidden = configuration.isSelected == false 81 | mark2.isHidden = configuration.isArchived == false 82 | } 83 | 84 | required init?(coder: NSCoder) { 85 | fatalError("init(coder:) has not been implemented") 86 | } 87 | 88 | } 89 | 90 | private struct CustomCellConfiguration: UIContentConfiguration { 91 | 92 | var text: String = "" 93 | var isSelected: Bool = false 94 | var isArchived: Bool = false 95 | 96 | public func makeContentView() -> UIView & UIContentView { 97 | let content = CustomCellContent(configuration: self) 98 | content.configuration = self 99 | return content 100 | } 101 | 102 | public func updated(for state: UIConfigurationState) -> Self { 103 | guard let cellState = state as? UICellConfigurationState else { 104 | assertionFailure() 105 | return self 106 | } 107 | print(cellState.isArchived) 108 | var new = self 109 | new.isSelected = cellState.isSelected 110 | new.isArchived = cellState.isArchived 111 | return new 112 | } 113 | 114 | } 115 | 116 | private var items: [Int] = (0..<100).map { $0 } 117 | 118 | private let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewCompositionalLayout.list(using: .init(appearance: .plain))) 119 | 120 | private let cellRegistration = UICollectionView.CellRegistration { cell, indexPath, item in 121 | 122 | var contentConfiguration = CustomCellConfiguration() 123 | 124 | contentConfiguration.text = "\(item)" 125 | // contentConfiguration.textProperties.color = .lightGray 126 | 127 | cell.contentConfiguration = contentConfiguration 128 | } 129 | 130 | override init(frame: CGRect) { 131 | super.init(frame: frame) 132 | 133 | let actionButton = UIButton(primaryAction: .init(title: "Action", handler: { [weak self] _ in 134 | 135 | guard let self else { return } 136 | 137 | print(collectionView.cellForItem(at: .init(item: 99, section: 0))) 138 | 139 | // if #available(iOS 15.0, *) { 140 | // collectionView.reconfigureItems(at: collectionView.indexPathsForVisibleItems) 141 | // } else { 142 | // // Fallback on earlier versions 143 | // } 144 | 145 | })) 146 | 147 | Mondrian.buildSubviews(on: self) { 148 | VStackBlock { 149 | actionButton 150 | collectionView 151 | } 152 | } 153 | 154 | 155 | collectionView.dataSource = self 156 | 157 | } 158 | 159 | required init?(coder: NSCoder) { 160 | fatalError("init(coder:) has not been implemented") 161 | } 162 | 163 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 164 | return items.count 165 | } 166 | 167 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 168 | 169 | let item = items[indexPath.item] 170 | 171 | let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) 172 | 173 | return cell 174 | } 175 | 176 | } 177 | 178 | } 179 | 180 | extension UIConfigurationStateCustomKey { 181 | static let isArchived = UIConfigurationStateCustomKey("com.my-app.MyCell.isArchived") 182 | } 183 | 184 | extension UICellConfigurationState { 185 | var isArchived: Bool { 186 | get { return self[.isArchived] as? Bool ?? false } 187 | set { self[.isArchived] = newValue } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /Sources/DynamicList/VersatileCell.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import UIKit 3 | 4 | public struct CellHighlightAnimationContext { 5 | public let cell: UICollectionViewCell 6 | } 7 | 8 | public protocol CellHighlightAnimation { 9 | 10 | @MainActor 11 | func onChange(isHighlighted: Bool, context: CellHighlightAnimationContext) 12 | } 13 | 14 | public struct DisabledCellHighlightAnimation: CellHighlightAnimation { 15 | 16 | public func onChange(isHighlighted: Bool, context: CellHighlightAnimationContext) { 17 | // no operation 18 | } 19 | } 20 | 21 | public struct ShrinkCellHighlightAnimation: CellHighlightAnimation { 22 | 23 | public func onChange(isHighlighted: Bool, context: CellHighlightAnimationContext) { 24 | 25 | let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) 26 | 27 | if isHighlighted { 28 | animator.addAnimations { 29 | context.cell.transform = .init(scaleX: 0.95, y: 0.95) 30 | } 31 | } else { 32 | animator.addAnimations { 33 | context.cell.transform = .identity 34 | } 35 | } 36 | animator.startAnimation() 37 | } 38 | } 39 | 40 | extension CellHighlightAnimation { 41 | 42 | public static func shrink( 43 | duration: TimeInterval = 0.4, 44 | dampingRatio: CGFloat = 1 45 | ) -> Self where Self == ShrinkCellHighlightAnimation { 46 | ShrinkCellHighlightAnimation() 47 | } 48 | 49 | } 50 | 51 | extension CellHighlightAnimation where Self == DisabledCellHighlightAnimation { 52 | public static var disabled: Self { 53 | DisabledCellHighlightAnimation() 54 | } 55 | } 56 | 57 | import Combine 58 | 59 | open class VersatileCell: UICollectionViewCell { 60 | 61 | open override var isHighlighted: Bool { 62 | didSet { 63 | guard oldValue != isHighlighted else { return } 64 | _highlightAnimation.onChange(isHighlighted: isHighlighted, context: .init(cell: self)) 65 | } 66 | } 67 | 68 | public internal(set) var customState: CellState = .init() 69 | 70 | public var _updateConfigurationHandler: @MainActor (_ cell: VersatileCell, _ state: UICellConfigurationState, _ customState: CellState) -> Void = { _, _ , _ in } 71 | 72 | private var _highlightAnimation: any CellHighlightAnimation = .disabled 73 | 74 | /// In prepareForReuse this is going to be a new instance. 75 | public var reusableCancellables: Set = .init() 76 | 77 | public override init( 78 | frame: CGRect 79 | ) { 80 | super.init(frame: frame) 81 | } 82 | 83 | @available(*, unavailable) 84 | public required init?( 85 | coder: NSCoder 86 | ) { 87 | fatalError() 88 | } 89 | 90 | open override func invalidateIntrinsicContentSize() { 91 | if #available(iOS 16, *) { 92 | // from iOS 16, auto-resizing runs 93 | super.invalidateIntrinsicContentSize() 94 | } else { 95 | super.invalidateIntrinsicContentSize() 96 | self.layoutWithInvalidatingCollectionViewLayout(animated: true) 97 | } 98 | } 99 | 100 | open override func prepareForReuse() { 101 | super.prepareForReuse() 102 | 103 | reusableCancellables = .init() 104 | } 105 | 106 | open override var configurationState: UICellConfigurationState { 107 | let state = super.configurationState 108 | return state 109 | } 110 | 111 | open override func updateConfiguration(using state: UICellConfigurationState) { 112 | super.updateConfiguration(using: state) 113 | _updateConfigurationHandler(self, state, customState) 114 | } 115 | 116 | open func updateContent(using customState: CellState) { 117 | _updateConfigurationHandler(self, configurationState, customState) 118 | } 119 | 120 | public func layoutWithInvalidatingCollectionViewLayout(animated: Bool) { 121 | 122 | guard let collectionView = (superview as? UICollectionView) else { 123 | return 124 | } 125 | 126 | if animated { 127 | 128 | UIView.animate( 129 | withDuration: 0.5, 130 | delay: 0, 131 | usingSpringWithDamping: 1, 132 | initialSpringVelocity: 0, 133 | options: [ 134 | .beginFromCurrentState, 135 | .allowUserInteraction, 136 | .overrideInheritedCurve, 137 | .overrideInheritedOptions, 138 | .overrideInheritedDuration, 139 | ], 140 | animations: { 141 | collectionView.layoutIfNeeded() 142 | collectionView.collectionViewLayout.invalidateLayout() 143 | }, 144 | completion: { (finish) in 145 | 146 | } 147 | ) 148 | 149 | } else { 150 | 151 | CATransaction.begin() 152 | CATransaction.setDisableActions(true) 153 | collectionView.layoutIfNeeded() 154 | collectionView.collectionViewLayout.invalidateLayout() 155 | CATransaction.commit() 156 | 157 | } 158 | } 159 | 160 | public func highlightAnimation(_ animation: any CellHighlightAnimation) -> Self { 161 | self._highlightAnimation = animation 162 | return self 163 | } 164 | 165 | open func customSizeFitting(size: CGSize) -> CGSize? { 166 | return nil 167 | } 168 | 169 | open override func preferredLayoutAttributesFitting( 170 | _ layoutAttributes: UICollectionViewLayoutAttributes 171 | ) -> UICollectionViewLayoutAttributes { 172 | 173 | guard 174 | let collectionView = (superview as? UICollectionView), 175 | let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout 176 | else { 177 | return super.preferredLayoutAttributesFitting(layoutAttributes) 178 | } 179 | 180 | let padding = flowLayout.sectionInset.left + flowLayout.sectionInset.right 181 | let maxWidth = collectionView.bounds.width - padding 182 | 183 | let targetSize = CGSize( 184 | width: maxWidth, 185 | height: UIView.layoutFittingCompressedSize.height 186 | ) 187 | 188 | let cellSize: CGSize 189 | 190 | if let customSize = customSizeFitting(size: targetSize) { 191 | cellSize = customSize 192 | } else { 193 | 194 | var size = systemLayoutSizeFitting( 195 | targetSize, 196 | withHorizontalFittingPriority: .fittingSizeLevel, 197 | verticalFittingPriority: .fittingSizeLevel 198 | ) 199 | 200 | if size.width > targetSize.width { 201 | // re-calculate size with max width. 202 | size = systemLayoutSizeFitting( 203 | targetSize, 204 | withHorizontalFittingPriority: .required, 205 | verticalFittingPriority: .fittingSizeLevel 206 | ) 207 | } 208 | 209 | cellSize = size 210 | } 211 | 212 | layoutAttributes.frame.size = cellSize 213 | 214 | return layoutAttributes 215 | 216 | } 217 | } 218 | #endif 219 | -------------------------------------------------------------------------------- /Development/Development/BookUIKitBasedFlow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import DynamicList 4 | import os 5 | 6 | 7 | nonisolated(unsafe) fileprivate var globalCount: Int = 0 8 | fileprivate func getGlobalCount() -> Int { 9 | globalCount &+= 1 10 | return globalCount 11 | } 12 | 13 | 14 | struct BookUIKitBasedFlow: View, PreviewProvider { 15 | var body: some View { 16 | Content() 17 | } 18 | 19 | static var previews: some View { 20 | Self() 21 | } 22 | 23 | private struct Content: View { 24 | 25 | var body: some View { 26 | _View() 27 | } 28 | } 29 | 30 | private struct _View: UIViewRepresentable { 31 | 32 | func makeUIView(context: Context) -> ContainerView { 33 | ContainerView() 34 | } 35 | 36 | func updateUIView(_ uiView: BookUIKitBasedFlow.ContainerView, context: Context) { 37 | 38 | } 39 | } 40 | 41 | enum Block: Hashable { 42 | case a(A) 43 | case b(B) 44 | 45 | struct A: Hashable { 46 | let id: Int = getGlobalCount() 47 | let name: String 48 | let introduction: String = random(count: (1..<5).randomElement()!) 49 | } 50 | 51 | struct B: Hashable { 52 | let id: Int = getGlobalCount() 53 | let name: String 54 | let introduction: String = random(count: (1..<5).randomElement()!) 55 | } 56 | } 57 | 58 | 59 | private final class ContainerView: UIView { 60 | 61 | private let list = DynamicListView( 62 | layout: { 63 | 64 | let flowLayout = UICollectionViewFlowLayout() 65 | flowLayout.scrollDirection = .vertical 66 | flowLayout.minimumLineSpacing = 0 67 | flowLayout.minimumInteritemSpacing = 0 68 | flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize 69 | 70 | return flowLayout 71 | }(), 72 | scrollDirection: .vertical 73 | ) 74 | 75 | // private let list = DynamicCompositionalLayoutView(scrollDirection: .vertical) 76 | 77 | private var currentData = (0..<50).flatMap { i in 78 | [ 79 | Block.a(.init(name: "\(i)")), 80 | Block.b(.init(name: "\(i)")), 81 | ] 82 | } 83 | 84 | init() { 85 | super.init(frame: .null) 86 | 87 | addSubview(list) 88 | list.translatesAutoresizingMaskIntoConstraints = false 89 | 90 | NSLayoutConstraint.activate([ 91 | list.topAnchor.constraint(equalTo: topAnchor), 92 | list.bottomAnchor.constraint(equalTo: bottomAnchor), 93 | list.leadingAnchor.constraint(equalTo: leadingAnchor), 94 | list.trailingAnchor.constraint(equalTo: trailingAnchor), 95 | ]) 96 | 97 | list.setUp( 98 | cellProvider: { context in 99 | 100 | switch context.data { 101 | case .a(let v): 102 | return context.cell(reuseIdentifier: "A") { state, _ in 103 | ComposableCell { 104 | TextField("Hello", text: .constant("Hoge")) 105 | HStack { 106 | Text("\(state.isHighlighted.description)") 107 | Text("\(v.name)") 108 | .redacted(reason: .placeholder) 109 | Text("\(v.introduction)") 110 | .redacted(reason: .placeholder) 111 | } 112 | } 113 | .onAppear { 114 | print("OnAppear", v.id) 115 | } 116 | } 117 | case .b(let v): 118 | return context.cell(reuseIdentifier: "B") { _, _ in 119 | Button { 120 | 121 | } label: { 122 | VStack { 123 | Button("Action") { 124 | print("Action") 125 | } 126 | Text("\(v.name)") 127 | .foregroundColor(Color.green) 128 | .redacted(reason: .placeholder) 129 | 130 | Text("\(v.introduction)") 131 | .foregroundColor(Color.green) 132 | .redacted(reason: .placeholder) 133 | } 134 | } 135 | .background(Color.red) 136 | 137 | } 138 | } 139 | 140 | } 141 | ) 142 | 143 | list.setIncrementalContentLoader { [weak list, weak self] in 144 | guard let self else { return } 145 | guard let list else { return } 146 | 147 | self.currentData.append( 148 | contentsOf: (0..<50).flatMap { i in 149 | [ 150 | Block.a(.init(name: "\(i)")), 151 | Block.b(.init(name: "\(i)")), 152 | ] 153 | } 154 | ) 155 | list.setContents(self.currentData, inSection: 0) 156 | } 157 | 158 | list.setSelectionHandler { action in 159 | switch action { 160 | case .didSelect(let item): 161 | print("Selected \(String(describing: item))") 162 | case .didDeselect(let item): 163 | print("Deselected \(String(describing: item))") 164 | } 165 | } 166 | 167 | list.setContents(currentData, inSection: 0) 168 | } 169 | 170 | required init?(coder: NSCoder) { 171 | fatalError("init(coder:) has not been implemented") 172 | } 173 | 174 | } 175 | 176 | private struct ComposableCell: View { 177 | 178 | @State var flag = false 179 | @Environment(\.versatileCell) var cell 180 | 181 | private let content: Content 182 | 183 | init(@ViewBuilder content: @escaping () -> Content) { 184 | self.content = content() 185 | } 186 | 187 | var body: some View { 188 | 189 | VStack { 190 | 191 | RoundedRectangle(cornerRadius: 8, style: .continuous) 192 | .frame(height: flag ? 60 : 120) 193 | .foregroundColor(Color.purple.opacity(0.2)) 194 | .overlay(Button("Toggle") { 195 | flag.toggle() 196 | DispatchQueue.main.async { 197 | cell?.invalidateIntrinsicContentSize() 198 | } 199 | }) 200 | 201 | content 202 | .padding(16) 203 | .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.green.opacity(0.2))) 204 | } 205 | 206 | } 207 | 208 | } 209 | } 210 | 211 | private final class _UICollectionViewCompositionalLayout: UICollectionViewCompositionalLayout { 212 | 213 | override func invalidateLayout() { 214 | super.invalidateLayout() 215 | } 216 | 217 | override func layoutAttributesForItem(at indexPath: IndexPath) 218 | -> UICollectionViewLayoutAttributes? 219 | { 220 | super.layoutAttributesForItem(at: indexPath) 221 | } 222 | 223 | override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { 224 | super.invalidateLayout(with: context) 225 | } 226 | } 227 | 228 | fileprivate func random(count: Int) -> String { 229 | 230 | let loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." 231 | 232 | // Split the lorem ipsum text into words 233 | let words = loremIpsum.components(separatedBy: " ") 234 | 235 | // Generate a random text with 10 words 236 | var randomText = "" 237 | for _ in 0..: View { 28 | 29 | /** 30 | The option to determine how to size the header. 31 | */ 32 | public enum Sizing { 33 | /// Uses the given content's intrinsic size. 34 | case content 35 | /// Uses the fixed height. 36 | case fixed(CGFloat) 37 | } 38 | 39 | public let sizing: Sizing 40 | public let content: (StickyHeaderContext) -> Content 41 | 42 | @State var baseContentHeight: CGFloat? 43 | @State var stretchingValue: CGFloat = 0 44 | @State var topMargin: CGFloat = 0 45 | 46 | public init( 47 | sizing: Sizing, 48 | @ViewBuilder content: @escaping (StickyHeaderContext) -> Content 49 | ) { 50 | self.sizing = sizing 51 | self.content = content 52 | } 53 | 54 | public var body: some View { 55 | 56 | 57 | let context = StickyHeaderContext( 58 | topMargin: topMargin, 59 | stretchingValue: stretchingValue, 60 | phase: stretchingValue > 0 ? .stretching : .idle 61 | ) 62 | 63 | Group { 64 | switch sizing { 65 | case .content: 66 | 67 | let height = stretchingValue > 0 68 | ? baseContentHeight.map { $0 + stretchingValue } 69 | : nil 70 | 71 | let baseContentHeight = stretchingValue > 0 ? self.baseContentHeight ?? 0 : nil 72 | 73 | content(context) 74 | .onGeometryChange(for: CGSize.self, of: \.size) { size in 75 | if stretchingValue == 0 { 76 | self.baseContentHeight = size.height 77 | } 78 | } 79 | .frame( 80 | height: height 81 | ) 82 | .offset(y: stretchingValue > 0 ? -stretchingValue : 0) 83 | // container 84 | .frame(height: baseContentHeight, alignment: .top) 85 | 86 | case .fixed(let height): 87 | 88 | let offsetY: CGFloat = 0 89 | 90 | content(context) 91 | .frame(height: height + stretchingValue + offsetY) 92 | .offset(y: -offsetY) 93 | .offset(y: -stretchingValue) 94 | // container 95 | .frame(height: height, alignment: .top) 96 | } 97 | } 98 | .onGeometryChange( 99 | for: Pair.self, 100 | of: { 101 | Pair( 102 | minYInGlobal: $0.frame(in: .global).minY, 103 | minYInCoordinateSpace: $0.frame(in: .scrollView).minY 104 | ) 105 | }, 106 | action: { pair in 107 | 108 | self.stretchingValue = max(0, pair.minYInCoordinateSpace) 109 | 110 | let minY = pair.minYInGlobal 111 | if minY >= 0, topMargin != minY { 112 | topMargin = minY - stretchingValue 113 | } 114 | } 115 | ) 116 | 117 | } 118 | } 119 | 120 | private struct Pair: Equatable { 121 | let minYInGlobal: CGFloat 122 | let minYInCoordinateSpace: CGFloat 123 | } 124 | 125 | #Preview("dynamic") { 126 | ScrollView { 127 | 128 | Section { 129 | 130 | ForEach(0..<100, id: \.self) { _ in 131 | Text("Hello World!") 132 | .frame(maxWidth: .infinity) 133 | } 134 | 135 | } header: { 136 | 137 | StickyHeader(sizing: .content) { context in 138 | 139 | ZStack { 140 | 141 | Rectangle() 142 | .stroke(lineWidth: 10) 143 | .padding(.top, -context.topMargin) 144 | // 145 | 146 | VStack { 147 | Text("StickyHeader") 148 | Text("StickyHeader") 149 | Text("StickyHeader") 150 | } 151 | .border(Color.red) 152 | .frame(maxWidth: .infinity, maxHeight: .infinity) 153 | // .background(.yellow) 154 | // .background( 155 | // Color.green 156 | // .padding(.top, -context.topMargin) 157 | // 158 | // ) 159 | } 160 | } 161 | } 162 | 163 | } 164 | } 165 | 166 | #Preview("dynamic full") { 167 | ScrollView { 168 | 169 | StickyHeader(sizing: .content) { context in 170 | 171 | ZStack { 172 | 173 | Color.red 174 | 175 | VStack { 176 | Text("StickyHeader") 177 | Text("StickyHeader") 178 | Text("StickyHeader") 179 | } 180 | .border(Color.red) 181 | .frame(maxWidth: .infinity, maxHeight: .infinity) 182 | .background(.yellow) 183 | .background( 184 | Color.green 185 | .padding(.top, -100) 186 | 187 | ) 188 | } 189 | 190 | } 191 | 192 | ForEach(0..<100, id: \.self) { _ in 193 | Text("Hello World!") 194 | .frame(maxWidth: .infinity) 195 | } 196 | } 197 | } 198 | 199 | #Preview("fixed") { 200 | ScrollView { 201 | 202 | StickyHeader(sizing: .fixed(300)) { context in 203 | 204 | Rectangle() 205 | .stroke(lineWidth: 10) 206 | .overlay( 207 | VStack { 208 | Text("StickyHeader") 209 | Text("StickyHeader") 210 | Text("StickyHeader") 211 | } 212 | ) 213 | } 214 | 215 | ForEach(0..<100, id: \.self) { _ in 216 | Text("Hello World!") 217 | .frame(maxWidth: .infinity) 218 | } 219 | } 220 | .padding(.vertical, 100) 221 | } 222 | 223 | #Preview("fixed full") { 224 | ScrollView { 225 | 226 | Section { 227 | 228 | ForEach(0..<100, id: \.self) { _ in 229 | Text("Hello World!") 230 | .frame(maxWidth: .infinity) 231 | } 232 | } header: { 233 | 234 | StickyHeader(sizing: .fixed(300)) { context in 235 | 236 | ZStack { 237 | 238 | Color.red 239 | .padding(.top, -context.topMargin) 240 | // 241 | 242 | VStack { 243 | Text("StickyHeader") 244 | Text("StickyHeader") 245 | Text("StickyHeader") 246 | } 247 | .border(Color.red) 248 | .frame(maxWidth: .infinity, maxHeight: .infinity) 249 | // .background(.yellow) 250 | // .background( 251 | // Color.green 252 | // .padding(.top, -context.topMargin) 253 | // 254 | // ) 255 | } 256 | } 257 | } 258 | .padding(.top, 20) 259 | 260 | } 261 | } 262 | 263 | 264 | #Preview("dynamic height change") { 265 | @Previewable @State var itemCount: Int = 3 266 | 267 | return ScrollView { 268 | 269 | StickyHeader(sizing: .content) { context in 270 | 271 | ZStack { 272 | 273 | Color.red 274 | 275 | VStack(spacing: 8) { 276 | ForEach(0.. 0) 15 | public let isPulling: Bool 16 | 17 | init( 18 | pullDistance: CGFloat, 19 | progress: Double, 20 | isThresholdReached: Bool, 21 | isPulling: Bool 22 | ) { 23 | self.pullDistance = pullDistance 24 | self.progress = progress 25 | self.isThresholdReached = isThresholdReached 26 | self.isPulling = isPulling 27 | } 28 | } 29 | 30 | /// A low-level control that detects pull gestures in a ScrollView. 31 | /// This provides the foundation for pull-to-refresh and similar interactions. 32 | /// 33 | /// Example usage: 34 | /// ```swift 35 | /// ScrollView { 36 | /// PullingControl( 37 | /// threshold: 80, 38 | /// isExpanding: isLoading, // Keep height at threshold when true 39 | /// onChange: { context in 40 | /// if context.isThresholdReached { 41 | /// // Handle threshold reached while pulling 42 | /// } 43 | /// } 44 | /// ) { context in 45 | /// if context.isThresholdReached { 46 | /// Text("Release to trigger!") 47 | /// } else if context.isPulling { 48 | /// Text("Pull progress: \(Int(context.progress * 100))%") 49 | /// } 50 | /// } 51 | /// 52 | /// // Your content 53 | /// } 54 | /// ``` 55 | public struct PullingControl: View { 56 | 57 | private let threshold: CGFloat 58 | private let isExpanding: Bool 59 | private let onChange: ((PullingContext) -> Void)? 60 | private let content: (PullingContext) -> Content 61 | 62 | @State private var pullDistance: CGFloat = 0 63 | 64 | public init( 65 | threshold: CGFloat = 80, 66 | isExpanding: Bool = false, 67 | onChange: ((PullingContext) -> Void)? = nil, 68 | @ViewBuilder content: @escaping (PullingContext) -> Content 69 | ) { 70 | self.threshold = threshold 71 | self.isExpanding = isExpanding 72 | self.onChange = onChange 73 | self.content = content 74 | } 75 | 76 | private func makeContext(pullDistance: CGFloat) -> PullingContext { 77 | let progress = min(1.0, max(0.0, pullDistance / threshold)) 78 | let isThresholdReached = pullDistance >= threshold 79 | let isPulling = pullDistance > 0 80 | 81 | return PullingContext( 82 | pullDistance: pullDistance, 83 | progress: progress, 84 | isThresholdReached: isThresholdReached, 85 | isPulling: isPulling 86 | ) 87 | } 88 | 89 | public var body: some View { 90 | let context = makeContext(pullDistance: pullDistance) 91 | let effectiveHeight = isExpanding ? threshold : pullDistance 92 | 93 | content(context) 94 | .frame(height: max(0, effectiveHeight)) 95 | .onGeometryChange( 96 | for: CGFloat.self, 97 | of: { geometry in 98 | geometry.frame(in: .scrollView).minY 99 | } 100 | ) { minY in 101 | // Only track positive overscroll 102 | pullDistance = max(0, minY) 103 | } 104 | .onChange(of: context) { _, newContext in 105 | onChange?(newContext) 106 | } 107 | } 108 | } 109 | 110 | // MARK: - Previews 111 | 112 | #Preview("Simple Text Indicator") { 113 | struct ContentView: View { 114 | 115 | var body: some View { 116 | 117 | ScrollView { 118 | VStack(spacing: 0) { 119 | PullingControl(threshold: 80) { context in 120 | VStack(spacing: 4) { 121 | if context.isPulling { 122 | Text( 123 | context.isThresholdReached 124 | ? "Threshold Reached!" : "Pulling..." 125 | ) 126 | .font(.caption) 127 | .foregroundColor( 128 | context.isThresholdReached ? .green : .secondary 129 | ) 130 | 131 | Text("Progress: \(Int(context.progress * 100))%") 132 | .font(.caption2) 133 | .foregroundColor(.gray) 134 | } 135 | } 136 | .frame(maxWidth: .infinity) 137 | .padding(.vertical, 8) 138 | } 139 | 140 | LazyVStack(spacing: 12) { 141 | ForEach(0..<20, id: \.self) { item in 142 | Text("Item \(item + 1)") 143 | .frame(maxWidth: .infinity) 144 | .padding() 145 | .background(Color.gray.opacity(0.1)) 146 | .cornerRadius(8) 147 | } 148 | } 149 | .padding() 150 | } 151 | } 152 | } 153 | } 154 | 155 | return ContentView() 156 | } 157 | 158 | #Preview("Debug Info") { 159 | struct ContentView: View { 160 | var body: some View { 161 | ScrollView { 162 | VStack(spacing: 0) { 163 | PullingControl(threshold: 100) { context in 164 | if context.isPulling { 165 | VStack(alignment: .leading, spacing: 2) { 166 | Text( 167 | "pullDistance: \(String(format: "%.1f", context.pullDistance))" 168 | ) 169 | Text("progress: \(String(format: "%.2f", context.progress))") 170 | Text( 171 | "isThresholdReached: \(context.isThresholdReached ? "true" : "false")" 172 | ) 173 | } 174 | .font(.caption) 175 | .foregroundColor(.secondary) 176 | .frame(maxWidth: .infinity) 177 | .padding(.vertical, 8) 178 | } 179 | } 180 | 181 | VStack(spacing: 16) { 182 | ForEach(0..<15) { index in 183 | Text("Content Row \(index + 1)") 184 | .frame(maxWidth: .infinity) 185 | .padding() 186 | .background(Color.gray.opacity(0.1)) 187 | .cornerRadius(8) 188 | } 189 | } 190 | .padding() 191 | } 192 | } 193 | } 194 | } 195 | 196 | return ContentView() 197 | } 198 | 199 | #Preview("With onChange Callback") { 200 | struct ContentView: View { 201 | var body: some View { 202 | ScrollView { 203 | VStack(spacing: 0) { 204 | PullingControl( 205 | threshold: 80, 206 | onChange: { context in 207 | print( 208 | "[onChange] pullDistance: \(String(format: "%.1f", context.pullDistance)), progress: \(String(format: "%.2f", context.progress)), isThresholdReached: \(context.isThresholdReached)" 209 | ) 210 | } 211 | ) { context in 212 | VStack(spacing: 4) { 213 | if context.isPulling { 214 | Text( 215 | context.isThresholdReached 216 | ? "Threshold Reached!" : "Pulling..." 217 | ) 218 | .font(.caption) 219 | .foregroundColor( 220 | context.isThresholdReached ? .green : .secondary 221 | ) 222 | 223 | Text("Progress: \(Int(context.progress * 100))%") 224 | .font(.caption2) 225 | .foregroundColor(.gray) 226 | } 227 | } 228 | .frame(maxWidth: .infinity) 229 | .padding(.vertical, 8) 230 | } 231 | 232 | VStack(spacing: 8) { 233 | Text("Check Console for Logs") 234 | .font(.headline) 235 | .foregroundColor(.secondary) 236 | .frame(maxWidth: .infinity) 237 | .padding() 238 | .background(Color.yellow.opacity(0.1)) 239 | .cornerRadius(8) 240 | } 241 | .padding() 242 | 243 | LazyVStack(spacing: 12) { 244 | ForEach(0..<20, id: \.self) { item in 245 | Text("Item \(item + 1)") 246 | .frame(maxWidth: .infinity) 247 | .padding() 248 | .background(Color.gray.opacity(0.1)) 249 | .cornerRadius(8) 250 | } 251 | } 252 | .padding() 253 | } 254 | } 255 | } 256 | } 257 | 258 | return ContentView() 259 | } 260 | -------------------------------------------------------------------------------- /Sources/PullingControl/RefreshControl.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct RefreshControlContext { 4 | 5 | public enum State { 6 | case idle 7 | case pulling(progress: Double) 8 | case refreshing 9 | case finishing 10 | } 11 | 12 | public let state: State 13 | public let pullDistance: CGFloat 14 | public let progress: Double 15 | 16 | init( 17 | state: State, 18 | pullDistance: CGFloat, 19 | progress: Double 20 | ) { 21 | self.state = state 22 | self.pullDistance = pullDistance 23 | self.progress = progress 24 | } 25 | } 26 | 27 | /// A customizable pull-to-refresh control for ScrollView. 28 | /// Built on top of PullingControl with async action support and refreshing state management. 29 | /// 30 | /// Example usage: 31 | /// ```swift 32 | /// ScrollView { 33 | /// RefreshControl(action: { 34 | /// await fetchData() 35 | /// }) { context in 36 | /// // Custom refresh indicator 37 | /// } 38 | /// 39 | /// // Your content 40 | /// } 41 | /// ``` 42 | public struct RefreshControl: View { 43 | 44 | private let threshold: CGFloat 45 | private let action: () async -> Void 46 | private let content: (RefreshControlContext) -> Content 47 | 48 | @State private var isRefreshing: Bool = false 49 | @State private var hasTriggered: Bool = false 50 | @State private var frozenPullDistance: CGFloat = 0 51 | 52 | public init( 53 | threshold: CGFloat = 80, 54 | action: @escaping () async -> Void, 55 | @ViewBuilder content: @escaping (RefreshControlContext) -> Content 56 | ) { 57 | self.threshold = threshold 58 | self.action = action 59 | self.content = content 60 | } 61 | 62 | public var body: some View { 63 | PullingControl( 64 | threshold: threshold, 65 | isExpanding: isRefreshing, 66 | onChange: { pullingContext in 67 | // Handle pulling state changes 68 | if !isRefreshing { 69 | if pullingContext.isThresholdReached && !hasTriggered { 70 | triggerRefresh() 71 | } else if !pullingContext.isPulling && hasTriggered { 72 | hasTriggered = false 73 | } 74 | } 75 | } 76 | ) { pullingContext in 77 | // Determine effective values based on refreshing state 78 | let effectivePullDistance = isRefreshing ? frozenPullDistance : pullingContext.pullDistance 79 | let effectiveProgress = isRefreshing ? (frozenPullDistance / threshold) : pullingContext.progress 80 | 81 | // Build RefreshControlContext from PullingContext 82 | let state: RefreshControlContext.State = { 83 | if isRefreshing { 84 | return .refreshing 85 | } else if pullingContext.isPulling { 86 | return .pulling(progress: effectiveProgress) 87 | } else { 88 | return .idle 89 | } 90 | }() 91 | 92 | let context = RefreshControlContext( 93 | state: state, 94 | pullDistance: effectivePullDistance, 95 | progress: effectiveProgress 96 | ) 97 | 98 | return content(context) 99 | } 100 | } 101 | 102 | private func triggerRefresh() { 103 | hasTriggered = true 104 | frozenPullDistance = threshold 105 | isRefreshing = true 106 | 107 | // Haptic feedback 108 | #if os(iOS) 109 | let impact = UIImpactFeedbackGenerator(style: .medium) 110 | impact.prepare() 111 | impact.impactOccurred() 112 | #endif 113 | 114 | Task { 115 | // Perform the refresh action 116 | await action() 117 | 118 | // Reset state with animation 119 | withAnimation(.easeOut(duration: 0.3)) { 120 | isRefreshing = false 121 | hasTriggered = false 122 | frozenPullDistance = 0 123 | } 124 | } 125 | } 126 | } 127 | 128 | 129 | 130 | // MARK: - Previews 131 | 132 | #Preview("Basic Refresh") { 133 | struct ContentView: View { 134 | @State private var items = Array(1...20) 135 | @State private var counter = 20 136 | 137 | var body: some View { 138 | ScrollView { 139 | VStack(spacing: 0) { 140 | RefreshControl(action: { 141 | // Simulate network delay 142 | try? await Task.sleep(nanoseconds: 2_000_000_000) 143 | 144 | // Add new items 145 | counter += 5 146 | items = Array(1...counter) 147 | }) { context in 148 | VStack(spacing: 8) { 149 | switch context.state { 150 | case .idle: 151 | EmptyView() 152 | case .pulling(let progress): 153 | Image(systemName: "arrow.down") 154 | .font(.system(size: 16, weight: .medium)) 155 | .foregroundColor(.secondary) 156 | .rotationEffect(.degrees(progress * 180)) 157 | .scaleEffect(0.8 + progress * 0.2) 158 | if progress > 0.5 { 159 | Text(progress >= 1.0 ? "Release to refresh" : "Pull to refresh") 160 | .font(.caption) 161 | .foregroundColor(.secondary) 162 | } 163 | case .refreshing: 164 | ProgressView() 165 | .progressViewStyle(CircularProgressViewStyle()) 166 | .scaleEffect(0.9) 167 | Text("Refreshing...") 168 | .font(.caption) 169 | .foregroundColor(.secondary) 170 | case .finishing: 171 | Image(systemName: "checkmark.circle.fill") 172 | .font(.system(size: 24)) 173 | .foregroundColor(.green) 174 | } 175 | } 176 | .frame(maxWidth: .infinity) 177 | .padding(.vertical, 8) 178 | } 179 | 180 | LazyVStack(spacing: 12) { 181 | ForEach(items, id: \.self) { item in 182 | HStack { 183 | Text("Item \(item)") 184 | .font(.headline) 185 | Spacer() 186 | Image(systemName: "chevron.right") 187 | .font(.caption) 188 | .foregroundColor(.secondary) 189 | } 190 | .padding() 191 | .background(Color.gray.opacity(0.1)) 192 | .cornerRadius(10) 193 | .shadow(radius: 2) 194 | } 195 | } 196 | .padding() 197 | } 198 | } 199 | .background(Color.gray.opacity(0.05)) 200 | } 201 | } 202 | 203 | return ContentView() 204 | } 205 | 206 | #Preview("Custom Indicator") { 207 | struct ContentView: View { 208 | @State private var lastRefresh = Date() 209 | 210 | var body: some View { 211 | ScrollView { 212 | VStack(spacing: 0) { 213 | RefreshControl(action: { 214 | try? await Task.sleep(nanoseconds: 1_500_000_000) 215 | lastRefresh = Date() 216 | }) { context in 217 | VStack(spacing: 4) { 218 | switch context.state { 219 | case .pulling: 220 | Circle() 221 | .fill(Color.blue.opacity(context.progress)) 222 | .frame(width: 30, height: 30) 223 | .overlay( 224 | Text("\(Int(context.progress * 100))%") 225 | .font(.caption2) 226 | .foregroundColor(.white) 227 | ) 228 | .scaleEffect(0.5 + context.progress * 0.5) 229 | 230 | case .refreshing: 231 | HStack(spacing: 4) { 232 | ForEach(0..<3) { index in 233 | Circle() 234 | .fill(Color.blue) 235 | .frame(width: 8, height: 8) 236 | .scaleEffect(1.0) 237 | .animation( 238 | Animation.easeInOut(duration: 0.6) 239 | .repeatForever() 240 | .delay(Double(index) * 0.2), 241 | value: context.pullDistance 242 | ) 243 | } 244 | } 245 | .padding(.vertical, 11) 246 | 247 | default: 248 | EmptyView() 249 | } 250 | } 251 | .frame(maxWidth: .infinity) 252 | .padding(.vertical, 8) 253 | } 254 | 255 | VStack(spacing: 16) { 256 | Text("Last refreshed:") 257 | .font(.headline) 258 | 259 | Text(lastRefresh, style: .relative) 260 | .font(.title2) 261 | .foregroundColor(.blue) 262 | 263 | ForEach(0..<10) { index in 264 | Text("Content Row \(index + 1)") 265 | .frame(maxWidth: .infinity) 266 | .padding() 267 | .background(Color.gray.opacity(0.1)) 268 | .cornerRadius(8) 269 | } 270 | } 271 | .padding() 272 | } 273 | } 274 | } 275 | } 276 | 277 | return ContentView() 278 | } 279 | -------------------------------------------------------------------------------- /Development/Development/BookUIKitBasedCompositional.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import DynamicList 4 | import os 5 | import MondrianLayout 6 | 7 | 8 | nonisolated(unsafe) fileprivate var globalCount: Int = 0 9 | 10 | fileprivate func getGlobalCount() -> Int { 11 | globalCount &+= 1 12 | return globalCount 13 | } 14 | 15 | enum IsArchivedKey: CustomStateKey { 16 | typealias Value = Bool 17 | 18 | static var defaultValue: Bool { false } 19 | } 20 | 21 | extension CellState { 22 | var isArchived: Bool { 23 | get { self[IsArchivedKey.self] } 24 | set { self[IsArchivedKey.self] = newValue } 25 | } 26 | } 27 | 28 | struct BookUIKitBasedCompositional: View, PreviewProvider { 29 | var body: some View { 30 | Content() 31 | } 32 | 33 | static var previews: some View { 34 | Self() 35 | } 36 | 37 | private struct Content: View { 38 | 39 | var body: some View { 40 | _View() 41 | } 42 | } 43 | 44 | private struct _View: UIViewRepresentable { 45 | 46 | func makeUIView(context: Context) -> ContainerView { 47 | ContainerView() 48 | } 49 | 50 | func updateUIView(_ uiView: BookUIKitBasedCompositional.ContainerView, context: Context) { 51 | 52 | } 53 | } 54 | 55 | enum Block: Hashable { 56 | case a(A) 57 | case b(B) 58 | 59 | struct A: Hashable { 60 | let id: Int = getGlobalCount() 61 | let name: String 62 | let introduction: String = random(count: (2..<20).randomElement()!) 63 | } 64 | 65 | struct B: Hashable { 66 | let id: Int = getGlobalCount() 67 | let name: String 68 | let introduction: String = random(count: (2..<20).randomElement()!) 69 | } 70 | } 71 | 72 | 73 | private final class ContainerView: UIView { 74 | 75 | private let list = DynamicListView( 76 | compositionalLayout: { 77 | // Define the size of each item in the grid 78 | let itemSize = NSCollectionLayoutSize( 79 | widthDimension: .fractionalWidth(0.25), 80 | heightDimension: .estimated(100) 81 | ) 82 | 83 | // Create an item using the defined size 84 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 85 | 86 | // Define the group size as 4 items across and 4 items down 87 | let groupSize = NSCollectionLayoutSize( 88 | widthDimension: .fractionalWidth(1.0), 89 | heightDimension: .estimated(100) 90 | ) 91 | 92 | // Create a group using the defined group size and item 93 | let group = NSCollectionLayoutGroup.horizontal( 94 | layoutSize: groupSize, 95 | subitem: item, 96 | count: 2 97 | ) 98 | 99 | // Create a section using the defined group 100 | let section = NSCollectionLayoutSection(group: group) 101 | 102 | let configuration = UICollectionViewCompositionalLayoutConfiguration() 103 | // configuration.boundarySupplementaryItems = [ 104 | // .init( 105 | // layoutSize: .init( 106 | // widthDimension: .fractionalWidth(1), 107 | // heightDimension: .estimated(100) 108 | // ), 109 | // elementKind: "Header", 110 | // alignment: .top 111 | // ), 112 | // ] 113 | 114 | // Create a compositional layout using the defined section 115 | let layout = _UICollectionViewCompositionalLayout(section: section, configuration: configuration) 116 | 117 | return layout 118 | }() 119 | ) 120 | 121 | // private let list = DynamicCompositionalLayoutView(scrollDirection: .vertical) 122 | 123 | private var currentData = (0..<50).flatMap { i in 124 | [ 125 | Block.a(.init(name: "\(i)")), 126 | Block.b(.init(name: "\(i)")), 127 | ] 128 | } 129 | 130 | init() { 131 | super.init(frame: .null) 132 | 133 | let actionButton = UIButton(primaryAction: .init(title: "Action", handler: { [unowned self] _ in 134 | 135 | let target = currentData[49] 136 | 137 | if #available(iOS 15.0, *) { 138 | 139 | let current = list.state(for: target, key: IsArchivedKey.self) ?? false 140 | 141 | list.setState(!current, key: IsArchivedKey.self, for: target) 142 | } else { 143 | // Fallback on earlier versions 144 | } 145 | 146 | })) 147 | 148 | Mondrian.buildSubviews(on: self) { 149 | VStackBlock { 150 | actionButton 151 | list 152 | } 153 | } 154 | 155 | list.setUp( 156 | cellProvider: { context in 157 | 158 | switch context.data { 159 | case .a(let v): 160 | return context.cell(reuseIdentifier: "A") { state, customState in 161 | ComposableCell { 162 | HStack { 163 | Text("\(state.isHighlighted.description)") 164 | Text("\(v.name)") 165 | .redacted(reason: .placeholder) 166 | Text("\(v.introduction)") 167 | .redacted(reason: .placeholder) 168 | } 169 | } 170 | .overlay(Color.red.opacity(0.8).opacity(customState.isArchived ? 1 : 0)) 171 | .onAppear { 172 | print("OnAppear", v.id) 173 | } 174 | } 175 | case .b(let v): 176 | return context.cell(reuseIdentifier: "B") { _, customState in 177 | Button { 178 | 179 | } label: { 180 | VStack { 181 | Button("Action") { 182 | print("Action") 183 | } 184 | Text("\(v.name)") 185 | .foregroundColor(Color.green) 186 | .redacted(reason: .placeholder) 187 | 188 | Text("\(v.introduction)") 189 | .foregroundColor(Color.green) 190 | .redacted(reason: .placeholder) 191 | } 192 | } 193 | .overlay(Color.red.opacity(0.8).opacity(customState.isArchived ? 1 : 0)) 194 | 195 | } 196 | } 197 | 198 | } 199 | ) 200 | 201 | list.setIncrementalContentLoader { [weak list, weak self] in 202 | guard let self else { return } 203 | guard let list else { return } 204 | 205 | self.currentData.append( 206 | contentsOf: (0..<50).flatMap { i in 207 | [ 208 | Block.a(.init(name: "\(i)")), 209 | Block.b(.init(name: "\(i)")), 210 | ] 211 | } 212 | ) 213 | list.setContents(self.currentData, inSection: 0) 214 | } 215 | 216 | list.setSelectionHandler { action in 217 | switch action { 218 | case .didSelect(let item): 219 | print("Selected \(String(describing: item))") 220 | case .didDeselect(let item): 221 | print("Deselected \(String(describing: item))") 222 | } 223 | } 224 | 225 | list.setContents(currentData, inSection: 0) 226 | } 227 | 228 | required init?(coder: NSCoder) { 229 | fatalError("init(coder:) has not been implemented") 230 | } 231 | 232 | } 233 | 234 | private struct ComposableCell: View { 235 | 236 | @State var flag = false 237 | @Environment(\.versatileCell) var cell 238 | 239 | private let content: Content 240 | 241 | init(@ViewBuilder content: @escaping () -> Content) { 242 | self.content = content() 243 | } 244 | 245 | var body: some View { 246 | 247 | VStack { 248 | 249 | RoundedRectangle(cornerRadius: 8, style: .continuous) 250 | .frame(height: flag ? 60 : 120) 251 | .foregroundColor(Color.purple.opacity(0.2)) 252 | .overlay(Button("Toggle") { 253 | flag.toggle() 254 | DispatchQueue.main.async { 255 | cell?.invalidateIntrinsicContentSize() 256 | } 257 | }) 258 | 259 | content 260 | .padding(16) 261 | .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.green.opacity(0.2))) 262 | } 263 | 264 | } 265 | 266 | } 267 | } 268 | 269 | private final class _UICollectionViewCompositionalLayout: UICollectionViewCompositionalLayout { 270 | 271 | override func invalidateLayout() { 272 | super.invalidateLayout() 273 | } 274 | 275 | override func layoutAttributesForItem(at indexPath: IndexPath) 276 | -> UICollectionViewLayoutAttributes? 277 | { 278 | super.layoutAttributesForItem(at: indexPath) 279 | } 280 | 281 | override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { 282 | super.invalidateLayout(with: context) 283 | } 284 | } 285 | 286 | fileprivate func random(count: Int) -> String { 287 | 288 | let loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." 289 | 290 | // Split the lorem ipsum text into words 291 | let words = loremIpsum.components(separatedBy: " ") 292 | 293 | // Generate a random text with 10 words 294 | var randomText = "" 295 | for _ in 0.. { 5 | case single(Data) 6 | case multiple(Set) 7 | } 8 | 9 | public struct DynamicList: UIViewRepresentable { 10 | 11 | public struct ScrollTarget { 12 | public let item: Item 13 | public let position: UICollectionView.ScrollPosition 14 | public let animated: Bool 15 | public let skipCondition: @MainActor (UIScrollView) -> Bool 16 | 17 | public init( 18 | item: Item, 19 | position: UICollectionView.ScrollPosition = .centeredVertically, 20 | skipCondition: @escaping @MainActor (UIScrollView) -> Bool, 21 | animated: Bool 22 | ) { 23 | self.item = item 24 | self.position = position 25 | self.animated = animated 26 | self.skipCondition = skipCondition 27 | } 28 | } 29 | 30 | private var selection: Binding?>? 31 | 32 | public typealias SelectionAction = DynamicListView.SelectionAction 33 | public typealias CellProviderContext = DynamicListView.CellProviderContext 34 | 35 | private let layout: @MainActor () -> UICollectionViewLayout 36 | 37 | private let cellProvider: (CellProviderContext) -> UICollectionViewCell 38 | 39 | private var selectionHandler: (@MainActor (SelectionAction) -> Void)? = nil 40 | private var scrollHandler: (@MainActor (UIScrollView, DynamicListViewScrollAction) -> Void)? = nil 41 | 42 | private var incrementalContentLoader: (@MainActor () async throws -> Void)? = nil 43 | private var onLoadHandler: (@MainActor (DynamicListView) -> Void)? = nil 44 | private let snapshot: NSDiffableDataSourceSnapshot 45 | 46 | private let scrollDirection: UICollectionView.ScrollDirection 47 | private let contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior 48 | private let cellStates: [Item: CellState] 49 | 50 | private var scrollingTarget: ScrollTarget? 51 | 52 | public init( 53 | snapshot: NSDiffableDataSourceSnapshot, 54 | selection: Binding?>? = nil, 55 | cellStates: [Item: CellState] = [:], 56 | layout: @escaping @MainActor () -> UICollectionViewLayout, 57 | scrollDirection: UICollectionView.ScrollDirection, 58 | contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior = .automatic, 59 | cellProvider: @escaping ( 60 | DynamicListView.CellProviderContext 61 | ) -> UICollectionViewCell 62 | ) { 63 | self.snapshot = snapshot 64 | self.layout = layout 65 | self.scrollDirection = scrollDirection 66 | self.contentInsetAdjustmentBehavior = contentInsetAdjustmentBehavior 67 | self.cellProvider = cellProvider 68 | self.selection = selection 69 | self.cellStates = cellStates 70 | } 71 | 72 | public consuming func scrolling(to item: ScrollTarget?) -> Self { 73 | self.scrollingTarget = item 74 | return self 75 | } 76 | 77 | public func makeUIView(context: Context) -> DynamicListView { 78 | 79 | let listView: DynamicListView = .init( 80 | layout: layout(), 81 | scrollDirection: scrollDirection, 82 | contentInsetAdjustmentBehavior: contentInsetAdjustmentBehavior 83 | ) 84 | 85 | listView.setUp(cellProvider: cellProvider) 86 | 87 | listView.setContents(snapshot: snapshot) 88 | 89 | if let scrollingTarget { 90 | 91 | listView.scroll( 92 | to: scrollingTarget.item, 93 | at: scrollingTarget.position, 94 | skipCondition: scrollingTarget.skipCondition, 95 | animated: scrollingTarget.animated 96 | ) 97 | } 98 | 99 | onLoadHandler?(listView) 100 | 101 | return listView 102 | } 103 | 104 | public func updateUIView(_ listView: DynamicListView, context: Context) { 105 | 106 | listView.setContents(snapshot: snapshot) 107 | 108 | listView.resetState() 109 | 110 | for (item, state) in cellStates { 111 | listView._setState(cellState: state, for: item) 112 | } 113 | 114 | if let selection { 115 | 116 | switch selection.wrappedValue { 117 | case .none: 118 | // TODO: deselect all of selected items 119 | break 120 | case .single(let data): 121 | listView.setAllowsMultipleSelection(false) 122 | listView.select(data: data, animated: false, scrollPosition: []) 123 | case .multiple(let dataSet): 124 | // TODO: reset before selecting 125 | listView.setAllowsMultipleSelection(true) 126 | for data in dataSet { 127 | listView.select(data: data, animated: false, scrollPosition: []) 128 | } 129 | } 130 | } 131 | 132 | if let selectionHandler { 133 | listView.setSelectionHandler(selectionHandler) 134 | } else { 135 | listView.setSelectionHandler({ _ in }) 136 | } 137 | 138 | if let incrementalContentLoader { 139 | listView.setIncrementalContentLoader(incrementalContentLoader) 140 | } else { 141 | listView.setIncrementalContentLoader { } 142 | } 143 | 144 | if let scrollHandler { 145 | listView.setScrollHandler(scrollHandler) 146 | } else { 147 | listView.setScrollHandler({ _, _ in }) 148 | } 149 | 150 | if let scrollingTarget { 151 | listView.scroll( 152 | to: scrollingTarget.item, 153 | at: scrollingTarget.position, 154 | skipCondition: scrollingTarget.skipCondition, 155 | animated: scrollingTarget.animated 156 | ) 157 | } 158 | } 159 | 160 | public consuming func selectionHandler( 161 | _ handler: @escaping @MainActor (DynamicListView.SelectionAction) -> Void 162 | ) -> Self { 163 | self.selectionHandler = handler 164 | return self 165 | } 166 | 167 | public consuming func scrollHandler( 168 | _ handler: @escaping @MainActor (UIScrollView, DynamicListViewScrollAction) -> Void 169 | ) -> Self { 170 | self.scrollHandler = handler 171 | return self 172 | } 173 | 174 | public consuming func incrementalContentLoading(_ loader: @escaping @MainActor () async throws -> Void) 175 | -> Self 176 | { 177 | incrementalContentLoader = loader 178 | return self 179 | } 180 | 181 | public consuming func onLoad(_ handler: @escaping @MainActor (DynamicListView) -> Void) 182 | -> Self 183 | { 184 | onLoadHandler = handler 185 | return self 186 | } 187 | 188 | } 189 | 190 | #if DEBUG 191 | struct DynamicList_Previews: PreviewProvider { 192 | 193 | enum Section: CaseIterable { 194 | case a 195 | case b 196 | case c 197 | } 198 | 199 | static let layout: UICollectionViewCompositionalLayout = { 200 | 201 | let item = NSCollectionLayoutItem( 202 | layoutSize: NSCollectionLayoutSize( 203 | widthDimension: .fractionalWidth(0.25), 204 | heightDimension: .estimated(100) 205 | ) 206 | ) 207 | 208 | let group = NSCollectionLayoutGroup.horizontal( 209 | layoutSize: NSCollectionLayoutSize( 210 | widthDimension: .fractionalWidth(1.0), 211 | heightDimension: .estimated(100) 212 | ), 213 | subitem: item, 214 | count: 2 215 | ) 216 | 217 | group.interItemSpacing = .fixed(16) 218 | 219 | // Create a section using the defined group 220 | let section = NSCollectionLayoutSection(group: group) 221 | 222 | section.contentInsets = .init(top: 0, leading: 24, bottom: 0, trailing: 24) 223 | section.interGroupSpacing = 24 224 | 225 | // Create a compositional layout using the defined section 226 | let layout = UICollectionViewCompositionalLayout(section: section) 227 | 228 | return layout 229 | }() 230 | 231 | static var previews: some View { 232 | DynamicList( 233 | snapshot: { 234 | var snapshot = NSDiffableDataSourceSnapshot() 235 | snapshot.appendSections([.a, .b, .c]) 236 | snapshot.appendItems(["A"], toSection: .a) 237 | snapshot.appendItems(["B"], toSection: .b) 238 | snapshot.appendItems(["C"], toSection: .c) 239 | return snapshot 240 | }(), 241 | cellStates: [ 242 | "A": { 243 | var cellState = CellState() 244 | cellState.isArchived = true 245 | return cellState 246 | }() 247 | ], 248 | layout: { Self.layout }, 249 | scrollDirection: .vertical 250 | ) { context in 251 | let cell = context.cell { _, customState in 252 | HStack { 253 | Text(context.data) 254 | if customState.isArchived { 255 | Text("archived") 256 | } 257 | } 258 | } 259 | .highlightAnimation(.shrink()) 260 | 261 | return cell 262 | } 263 | .selectionHandler { value in 264 | print(value) 265 | } 266 | .incrementalContentLoading { 267 | 268 | } 269 | .onLoad { view in 270 | print(view) 271 | } 272 | 273 | } 274 | } 275 | 276 | enum IsArchivedKey: CustomStateKey { 277 | static var defaultValue: Bool { false } 278 | 279 | typealias Value = Bool 280 | } 281 | 282 | extension CellState { 283 | var isArchived: Bool { 284 | get { self[IsArchivedKey.self] } 285 | set { self[IsArchivedKey.self] = newValue } 286 | } 287 | } 288 | 289 | #endif 290 | 291 | #endif 292 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct CollectionView< 4 | Content: View, 5 | Layout: CollectionViewLayoutType 6 | >: View { 7 | 8 | public let content: Content 9 | 10 | public let layout: Layout 11 | 12 | public init( 13 | layout: Layout, 14 | @ViewBuilder content: () -> Content 15 | ) { 16 | self.layout = layout 17 | self.content = content() 18 | } 19 | 20 | public var body: some View { 21 | Group { 22 | ModifiedContent( 23 | content: content, 24 | modifier: layout 25 | ) 26 | } 27 | } 28 | 29 | } 30 | 31 | #if canImport(ScrollTracking) 32 | 33 | @_spi(Internal) 34 | import ScrollTracking 35 | 36 | extension CollectionView { 37 | 38 | /// Attaches an infinite‑scroll style loader to a CollectionView that calls your async closure 39 | /// as the user approaches the end of the scrollable content. 40 | /// 41 | /// Use this modifier to automatically request and append more data to a `CollectionView` when the 42 | /// user scrolls near the end. The modifier observes the underlying scroll view produced by the 43 | /// selected layout and triggers `onLoad` once the remaining distance to the end is within 44 | /// `leadingScreens` times the visible length. If the content is initially smaller than the viewport, 45 | /// the loader triggers immediately so you can populate the view. 46 | /// 47 | /// The `isLoading` binding is managed for you: it is set to `true` just before `onLoad` runs and 48 | /// reset to `false` when it finishes. While `isLoading` is `true`, additional triggers are suppressed. 49 | /// Only one load task runs at a time, and subsequent triggers are slightly debounced to avoid rapid 50 | /// re‑invocation when the user hovers near the threshold. 51 | /// 52 | /// - Parameters: 53 | /// - isEnabled: Toggles the behavior on or off. When `false`, no loading is triggered. Default is `true`. 54 | /// - leadingScreens: The prefetch threshold expressed in multiples of the visible scrollable length 55 | /// (height for vertical, width for horizontal). For example, `2` triggers when the user is within 56 | /// two screenfuls of the end. Default is `2`. 57 | /// - axis: The scroll axis to monitor. Use `.vertical` for vertical scrolling or `.horizontal` for 58 | /// horizontal scrolling. Default is `.vertical`. 59 | /// - isLoading: A binding that reflects the current loading state. This modifier sets it to `true` 60 | /// before calling `onLoad` and back to `false` when `onLoad` completes. 61 | /// - onLoad: An async closure executed on the main actor when the threshold is crossed. Perform your 62 | /// data fetch and append logic here. 63 | /// 64 | /// - Returns: A view that monitors scrolling and triggers `onLoad` according to the provided parameters. 65 | /// 66 | /// - Important: Avoid starting additional loads inside `onLoad` while `isLoading` is `true`. The 67 | /// modifier already prevents re‑entrancy by tracking the current load task and debouncing subsequent 68 | /// triggers. 69 | /// 70 | /// - Note: 71 | /// - If the content length is smaller than the viewport, `onLoad` is triggered once on appear so 72 | /// you can fetch enough items to fill the screen. 73 | /// - Use non‑negative values for `leadingScreens`. Values near `0` trigger close to the end; larger 74 | /// values prefetch earlier. 75 | /// 76 | /// - SeeAlso: 77 | /// - ``onAdditionalLoading(isEnabled:leadingScreens:axis:isLoading:onLoad:)`` (non‑binding overload) 78 | /// - ``ScrollView/onAdditionalLoading(isEnabled:leadingScreens:axis:isLoading:onLoad:)`` 79 | /// - ``List/onAdditionalLoading(isEnabled:leadingScreens:axis:isLoading:onLoad:)`` 80 | /// 81 | /// - Platform: 82 | /// - On iOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2 or later, the modifier uses SwiftUI 83 | /// scroll geometry to observe position. 84 | /// - On earlier supported iOS versions, it relies on scroll view introspection to observe content offset. 85 | /// 86 | /// - Example: 87 | /// ```swift 88 | /// struct FeedView: View { 89 | /// @State private var items: [Item] = [] 90 | /// @State private var isLoading = false 91 | /// 92 | /// var body: some View { 93 | /// CollectionView(layout: .list) { 94 | /// ForEach(items) { item in 95 | /// Row(item: item) 96 | /// } 97 | /// } 98 | /// .onAdditionalLoading(isEnabled: true, 99 | /// leadingScreens: 1.5, 100 | /// axis: .vertical, 101 | /// isLoading: $isLoading) { 102 | /// // Fetch more and append 103 | /// try? await Task.sleep(for: .seconds(1)) 104 | /// let more = await fetchMoreItems() 105 | /// items.append(contentsOf: more) 106 | /// } 107 | /// } 108 | /// } 109 | /// ``` 110 | @ViewBuilder 111 | public func onAdditionalLoading( 112 | isEnabled: Bool = true, 113 | leadingScreens: Double = 2, 114 | axis: Axis = .vertical, 115 | isLoading: Binding, 116 | onLoad: @MainActor @escaping () async -> Void 117 | ) -> some View { 118 | 119 | self.onAdditionalLoading( 120 | additionalLoading: .init( 121 | isEnabled: isEnabled, 122 | leadingScreens: leadingScreens, 123 | isLoading: isLoading, 124 | axis: axis, 125 | onLoad: onLoad 126 | ) 127 | ) 128 | 129 | } 130 | 131 | /// Triggers a load-more action as the user approaches the end of the scrollable content, 132 | /// without managing any loading state internally. 133 | /// 134 | /// This modifier observes the scroll position of the collection and invokes `onLoad` when 135 | /// the visible region nears the end of the content by the amount specified in `leadingScreens`. 136 | /// It is conditionally available when the ScrollTracking module can be imported. 137 | /// 138 | /// Use this overload when you already manage loading state externally (e.g., in a view model) 139 | /// and simply want a callback to fire when additional content should be fetched. If you want 140 | /// the modifier to help manage loading state and support async work, consider the binding-based, 141 | /// async overload instead. 142 | /// 143 | /// - Parameters: 144 | /// - isEnabled: A Boolean that enables or disables additional loading. When `false`, no callbacks 145 | /// are fired. Defaults to `true`. 146 | /// - leadingScreens: The prefetch distance, expressed as a multiple of the current viewport length 147 | /// (height for vertical, width for horizontal). For example, `2` means `onLoad` is triggered once 148 | /// the user scrolls within two screen-lengths of the end of the content. Defaults to `2`. 149 | /// - axis: The scroll axis to monitor. Use `.vertical` for vertical scrolling or `.horizontal` for 150 | /// horizontal scrolling. Default is `.vertical`. 151 | /// - isLoading: A Boolean that indicates whether a load is currently in progress. When `true`, 152 | /// additional triggers are suppressed. This value is read-only from the modifier's perspective; 153 | /// you are responsible for updating it in your own state to avoid duplicate loads. 154 | /// - onLoad: A closure executed on the main actor when the threshold is crossed and `isLoading` is `false`. 155 | /// Use this to kick off your loading logic (e.g., dispatch an async task or call into a view model). 156 | /// 157 | /// - Returns: A view that monitors scroll position and invokes `onLoad` as the user approaches the end. 158 | /// 159 | /// - Discussion: 160 | /// - The callback will not be invoked if the content is not scrollable, if `isEnabled` is `false`, 161 | /// or while `isLoading` is `true`. 162 | /// - Because this overload does not mutate `isLoading`, your code must set and clear loading state 163 | /// to prevent repeated triggers. 164 | /// - Choose `leadingScreens` based on your data-fetch latency and UI needs; values between `0.5` and `3` 165 | /// are common depending on how early you want to prefetch. 166 | /// - The `onLoad` closure runs on the main actor; if you need to perform asynchronous work, 167 | /// start a `Task { ... }` inside the closure or delegate to your view model. 168 | /// 169 | /// - SeeAlso: The binding-based async overload: 170 | /// `onAdditionalLoading(isEnabled:leadingScreens:axis:isLoading:onLoad:)` where `isLoading` is a `Binding` 171 | /// and `onLoad` is `async`, which can simplify state management for loading. 172 | /// 173 | /// - Example: 174 | /// ```swift 175 | /// struct FeedView: View { 176 | /// @StateObject private var viewModel = FeedViewModel() 177 | /// 178 | /// var body: some View { 179 | /// CollectionView(layout: viewModel.layout) { 180 | /// ForEach(viewModel.items) { item in 181 | /// FeedRow(item: item) 182 | /// } 183 | /// } 184 | /// .onAdditionalLoading( 185 | /// isEnabled: true, 186 | /// leadingScreens: 1.5, 187 | /// axis: .vertical, 188 | /// isLoading: viewModel.isLoading 189 | /// ) { 190 | /// // Executed on the main actor 191 | /// viewModel.loadMore() 192 | /// } 193 | /// } 194 | /// } 195 | /// ``` 196 | @ViewBuilder 197 | public func onAdditionalLoading( 198 | isEnabled: Bool = true, 199 | leadingScreens: Double = 2, 200 | axis: Axis = .vertical, 201 | isLoading: Bool, 202 | onLoad: @escaping @MainActor () -> Void 203 | ) -> some View { 204 | self.onAdditionalLoading( 205 | additionalLoading: .init( 206 | isEnabled: isEnabled, 207 | leadingScreens: leadingScreens, 208 | isLoading: isLoading, 209 | axis: axis, 210 | onLoad: onLoad 211 | ) 212 | ) 213 | } 214 | 215 | } 216 | 217 | #endif 218 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Development/Development/BookCollectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @testable import CollectionView 4 | @testable import SelectableForEach 5 | 6 | struct BookCollectionViewSingleSection: View, PreviewProvider { 7 | 8 | var body: some View { 9 | ContentView() 10 | } 11 | 12 | static var previews: some View { 13 | Self() 14 | .previewDisplayName(nil) 15 | } 16 | 17 | private struct ContentView: View { 18 | 19 | @State var selected: Item? 20 | 21 | var body: some View { 22 | CollectionView( 23 | layout: .list 24 | .contentPadding(.init(top: 0, leading: 0, bottom: 0, trailing: 0)), 25 | content: { 26 | SelectableForEach( 27 | data: Item.mock(), 28 | selection: .single( 29 | selected: selected, 30 | onChange: { e in 31 | selected = e 32 | } 33 | ), 34 | selectionIdentifier: \.self, 35 | cell: { index, item in 36 | Cell(index: index, item: item) 37 | } 38 | ) 39 | } 40 | ) 41 | 42 | } 43 | } 44 | 45 | } 46 | 47 | struct BookCollectionViewSingleSectionNoSeparator: View, PreviewProvider { 48 | 49 | var body: some View { 50 | ContentView() 51 | } 52 | 53 | static var previews: some View { 54 | Self() 55 | .previewDisplayName(nil) 56 | } 57 | 58 | private struct ContentView: View { 59 | 60 | @State var selected: Item? 61 | 62 | var body: some View { 63 | CollectionView( 64 | layout: .list, 65 | content: { 66 | SelectableForEach( 67 | data: Item.mock(), 68 | selection: .single( 69 | selected: selected, 70 | onChange: { e in 71 | selected = e 72 | } 73 | ), 74 | selectionIdentifier: \.self, 75 | cell: { index, item in 76 | Cell(index: index, item: item) 77 | } 78 | ) 79 | } 80 | ) 81 | 82 | } 83 | } 84 | 85 | } 86 | 87 | struct BookCollectionViewCombined: View, PreviewProvider { 88 | 89 | var body: some View { 90 | ContentView() 91 | } 92 | 93 | static var previews: some View { 94 | Self() 95 | .previewDisplayName(nil) 96 | } 97 | 98 | private struct ContentView: View { 99 | 100 | @State var selected: Item? 101 | @State var selected2: Item? 102 | 103 | var body: some View { 104 | CollectionView( 105 | layout: .list, 106 | content: { 107 | 108 | Text("Static content") 109 | .overlay(content: { 110 | 111 | }) 112 | 113 | Text("📱❄️") 114 | 115 | SelectableForEach( 116 | data: Item.mock(10), 117 | selection: .single( 118 | selected: selected, 119 | onChange: { e in 120 | selected = e 121 | } 122 | ), 123 | selectionIdentifier: \.self, 124 | cell: { index, item in 125 | Cell(index: index, item: item) 126 | } 127 | ) 128 | 129 | Text("📱❄️") 130 | 131 | SelectableForEach( 132 | data: Item.mock(10), 133 | selection: .single( 134 | selected: selected2, 135 | onChange: { e in 136 | selected2 = e 137 | } 138 | ), 139 | selectionIdentifier: \.self, 140 | cell: { index, item in 141 | Cell(index: index, item: item) 142 | } 143 | ) 144 | 145 | Text("📱❄️") 146 | 147 | ForEach(Item.mock(10)) { item in 148 | Cell(index: item.id, item: item) 149 | } 150 | 151 | } 152 | ) 153 | } 154 | } 155 | 156 | } 157 | 158 | #Preview("Custom List / Single selection") { 159 | 160 | struct Book: View { 161 | 162 | @State var selected: Item? 163 | 164 | var body: some View { 165 | CollectionView( 166 | layout: .list, 167 | content: { 168 | SelectableForEach( 169 | data: Item.mock(), 170 | selection: .single( 171 | selected: selected, 172 | onChange: { e in 173 | selected = e 174 | } 175 | ), 176 | selectionIdentifier: \.self, 177 | cell: { index, item in 178 | Cell(index: index, item: item) 179 | } 180 | ) 181 | } 182 | ) 183 | } 184 | } 185 | 186 | return Book() 187 | } 188 | 189 | #Preview("H / Custom List / Single selection") { 190 | 191 | struct Book: View { 192 | 193 | @State var selected: Item? 194 | 195 | var body: some View { 196 | CollectionView( 197 | layout: CollectionViewLayouts.List( 198 | direction: .horizontal 199 | ), 200 | content: { 201 | SelectableForEach( 202 | data: Item.mock(30), 203 | selection: .single( 204 | selected: selected, 205 | onChange: { e in 206 | selected = e 207 | } 208 | ), 209 | selectionIdentifier: \.self, 210 | cell: { index, item in 211 | Cell(index: index, item: item) 212 | } 213 | ) 214 | } 215 | ) 216 | .onAdditionalLoading(isEnabled: true, leadingScreens: 1, isLoading: .constant(true)) { 217 | print("onLoad next") 218 | } 219 | } 220 | } 221 | 222 | return Book() 223 | } 224 | 225 | 226 | #Preview("Custom List / Multiple selection") { 227 | 228 | struct Book: View { 229 | 230 | @State var selected: Set = .init() 231 | 232 | var body: some View { 233 | 234 | CollectionView( 235 | layout: .list, 236 | content: { 237 | SelectableForEach( 238 | data: Item.mock(), 239 | selection: .multiple( 240 | selected: selected, 241 | canSelectMore: selected.count < 3, 242 | onChange: { e, action in 243 | switch action { 244 | case .selected: 245 | selected.insert(e) 246 | case .deselected: 247 | selected.remove(e) 248 | } 249 | } 250 | ), 251 | selectionIdentifier: \.id, 252 | cell: { index, item in 253 | Cell(index: index, item: item) 254 | } 255 | ) 256 | } 257 | ) 258 | } 259 | } 260 | 261 | return Book() 262 | } 263 | 264 | struct BookPlatformList: View, PreviewProvider { 265 | var body: some View { 266 | ContentView() 267 | } 268 | 269 | static var previews: some View { 270 | Self() 271 | .previewDisplayName(nil) 272 | } 273 | 274 | private struct ContentView: View { 275 | 276 | @State var selected: Item? 277 | 278 | var body: some View { 279 | CollectionView( 280 | layout: CollectionViewLayouts.PlatformListVanilla(), 281 | content: { 282 | SelectableForEach( 283 | data: Item.mock(), 284 | selection: .single( 285 | selected: selected, 286 | onChange: { e in 287 | selected = e 288 | } 289 | ), 290 | selectionIdentifier: \.self, 291 | cell: { index, item in 292 | Cell(index: index, item: item) 293 | } 294 | ) 295 | } 296 | ) 297 | } 298 | } 299 | } 300 | 301 | #Preview("PlatformList") { 302 | BookPlatformList() 303 | } 304 | 305 | #Preview { 306 | 307 | struct BookList: View { 308 | 309 | struct Ocean: Identifiable, Hashable { 310 | let name: String 311 | let id = UUID() 312 | } 313 | 314 | private var oceans = [ 315 | Ocean(name: "Pacific"), 316 | Ocean(name: "Atlantic"), 317 | Ocean(name: "Indian"), 318 | Ocean(name: "Southern"), 319 | Ocean(name: "Arctic"), 320 | ] 321 | 322 | @State private var multiSelection = Set() 323 | 324 | var body: some View { 325 | NavigationView { 326 | List(oceans, selection: $multiSelection) { 327 | Text($0.name) 328 | } 329 | .navigationTitle("Oceans") 330 | // .toolbar { EditButton() } 331 | } 332 | Text("\(multiSelection.count) selections") 333 | } 334 | 335 | } 336 | 337 | return BookList() 338 | } 339 | 340 | #Preview("Inline Single selection") { 341 | 342 | struct Preview: View { 343 | 344 | @State var selected: Item.ID? 345 | 346 | var body: some View { 347 | 348 | SelectableForEach( 349 | data: Item.mock(10), 350 | selection: .single( 351 | selected: selected, 352 | onChange: { selected in 353 | self.selected = selected 354 | } 355 | ), 356 | selectionIdentifier: \.id, 357 | cell: { index, item in 358 | Cell(index: index, item: item) 359 | } 360 | ) 361 | 362 | } 363 | } 364 | 365 | return Preview() 366 | } 367 | 368 | #Preview("Inline Multiple selection") { 369 | 370 | struct Preview: View { 371 | 372 | @State var selected: Set = .init() 373 | 374 | var body: some View { 375 | 376 | SelectableForEach( 377 | data: Item.mock(10), 378 | selection: .multiple( 379 | selected: selected, 380 | canSelectMore: selected.count < 3, 381 | onChange: { e, action in 382 | switch action { 383 | case .selected: 384 | selected.insert(e) 385 | case .deselected: 386 | selected.remove(e) 387 | } 388 | } 389 | ), 390 | selectionIdentifier: \.id, 391 | cell: { index, item in 392 | Cell(index: index, item: item) 393 | } 394 | ) 395 | 396 | } 397 | } 398 | 399 | return Preview() 400 | } 401 | 402 | #Preview("Scrollable Multiple selection") { 403 | 404 | struct Preview: View { 405 | 406 | @State var selected: Set = .init() 407 | 408 | var body: some View { 409 | 410 | ScrollView { 411 | 412 | header 413 | .padding(.horizontal, 20) 414 | 415 | SelectableForEach( 416 | data: Item.mock(10), 417 | selection: .multiple( 418 | selected: selected, 419 | canSelectMore: selected.count < 3, 420 | onChange: { e, action in 421 | switch action { 422 | case .selected: 423 | selected.insert(e) 424 | case .deselected: 425 | selected.remove(e) 426 | } 427 | } 428 | ), 429 | selectionIdentifier: \.id, 430 | cell: { index, item in 431 | Cell(index: index, item: item) 432 | } 433 | ) 434 | } 435 | 436 | } 437 | 438 | private var header: some View { 439 | ZStack { 440 | RoundedRectangle(cornerRadius: 16) 441 | .fill(Color(white: 0, opacity: 0.2)) 442 | Text("Header") 443 | } 444 | .frame(height: 120) 445 | } 446 | } 447 | 448 | return Preview() 449 | } 450 | 451 | protocol SomeExistential {} 452 | 453 | private struct GridCell: View { 454 | 455 | let index: Int 456 | let item: Item 457 | var object: (any SomeExistential)? = nil 458 | 459 | var body: some View { 460 | let _ = Self._printChanges() 461 | let _ = print("GridCell \(index)") 462 | VStack { 463 | HStack { 464 | Text(item.title) 465 | } 466 | } 467 | } 468 | 469 | } 470 | 471 | #Preview("Simple grid layout") { 472 | 473 | struct Book: View { 474 | 475 | struct Wrap: View { 476 | 477 | let action: () -> Void 478 | let content: Content 479 | 480 | init( 481 | action: @escaping () -> Void, 482 | @ViewBuilder content: () -> Content 483 | ) { 484 | self.action = action 485 | self.content = content() 486 | } 487 | 488 | var body: some View { 489 | content 490 | } 491 | } 492 | 493 | @State var selected: Set = .init() 494 | 495 | var body: some View { 496 | 497 | CollectionView( 498 | layout: .grid( 499 | gridItems: [ 500 | .init(.flexible(minimum: 0, maximum: .infinity), spacing: 16), 501 | .init(.flexible(minimum: 0, maximum: .infinity), spacing: 16), 502 | .init(.flexible(minimum: 0, maximum: .infinity), spacing: 16), 503 | ], 504 | direction: .vertical 505 | ), 506 | content: { 507 | Section { 508 | ForEach( 509 | Item.mock(1000) 510 | ) { item in 511 | Control { 512 | print("hit") 513 | } content: { 514 | GridCell( 515 | index: 0, 516 | item: item 517 | ) 518 | } 519 | } 520 | } footer: { 521 | Text("Section Footer") 522 | } 523 | 524 | } 525 | ) 526 | } 527 | } 528 | 529 | return Book() 530 | 531 | } 532 | 533 | struct Control: View { 534 | 535 | let action: () -> Void 536 | let content: Content 537 | 538 | @State private var isPressing: Bool = false 539 | 540 | init( 541 | action: @escaping () -> Void, 542 | @ViewBuilder content: () -> Content 543 | ) { 544 | self.action = action 545 | self.content = content() 546 | } 547 | 548 | var body: some View { 549 | content 550 | .contentShape(Rectangle()) 551 | .allowsHitTesting(true) 552 | ._onButtonGesture( 553 | pressing: { isPressing in 554 | self.isPressing = isPressing 555 | } 556 | ) { 557 | action() 558 | } 559 | .modifier( 560 | isPressing ? 561 | ControlStyleModifier( 562 | opacity: 0.5 563 | ) : ControlStyleModifier() 564 | ) 565 | .animation(.spring(response: 0.2, dampingFraction: 1, blendDuration: 0), value: isPressing) 566 | } 567 | } 568 | 569 | public struct ControlStyleModifier: ViewModifier { 570 | 571 | public let opacity: Double 572 | public let scale: CGSize 573 | public let overlayColor: Color 574 | public let offset: CGSize 575 | public let blurRadius: Double 576 | 577 | public init( 578 | opacity: Double = 1, 579 | scale: CGSize = .init(width: 1, height: 1), 580 | overlayColor: Color = .clear, 581 | offset: CGSize = .zero, 582 | blurRadius: Double = 0 583 | ) { 584 | self.opacity = opacity 585 | self.scale = scale 586 | self.overlayColor = overlayColor 587 | self.offset = offset 588 | self.blurRadius = blurRadius 589 | } 590 | 591 | public func body(content: Content) -> some View { 592 | 593 | content 594 | .opacity(opacity) 595 | .overlay(overlayColor) 596 | .scaleEffect(scale) 597 | .offset(offset) 598 | .blur(radius: blurRadius) 599 | } 600 | 601 | } 602 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI List Support 2 | 3 | A comprehensive collection of essential components for building advanced list-based UIs in SwiftUI, with high-performance UIKit bridges when needed. 4 | 5 | ## Requirements 6 | 7 | - iOS 17.0+ 8 | - macOS 15.0+ 9 | - Swift 6.0 10 | 11 | ## Installation 12 | 13 | ### Swift Package Manager 14 | 15 | Add the following to your `Package.swift`: 16 | 17 | ```swift 18 | dependencies: [ 19 | .package(url: "https://github.com/FluidGroup/swiftui-list-support.git", from: "1.0.0") 20 | ] 21 | ``` 22 | 23 | Or add it through Xcode: 24 | 1. Go to File → Add Package Dependencies 25 | 2. Enter the repository URL: `https://github.com/FluidGroup/swiftui-list-support.git` 26 | 3. Select the modules you need 27 | 28 | ## Modules 29 | 30 | ### 1. DynamicList - UIKit UICollectionView Bridge 31 | 32 | A UIKit-based `UICollectionView` implementation wrapped for SwiftUI, providing maximum performance for large datasets with SwiftUI cell hosting. 33 | 34 | #### Key Components: 35 | - **DynamicListView**: UIKit UICollectionView with NSDiffableDataSource 36 | - **VersatileCell**: Flexible cell supporting SwiftUI content via hosting 37 | - **ContentPagingTrigger**: Automatic content loading as user scrolls 38 | - **CellState**: Custom state storage for cells 39 | 40 | #### SwiftUI Usage: 41 | 42 | ```swift 43 | import DynamicList 44 | import SwiftUI 45 | 46 | struct MyListView: View { 47 | var body: some View { 48 | DynamicList( 49 | snapshot: snapshot, 50 | layout: { 51 | UICollectionViewCompositionalLayout.list( 52 | using: .init(appearance: .plain) 53 | ) 54 | }, 55 | scrollDirection: .vertical 56 | ) { context in 57 | context.cell { state in 58 | // SwiftUI content in cell 59 | Text("Item: \(context.data.title)") 60 | .padding() 61 | } 62 | } 63 | .incrementalContentLoading { 64 | await loadMoreData() 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | 71 | #### Custom Cell States: 72 | 73 | ```swift 74 | // Define custom state key 75 | enum IsArchivedKey: CustomStateKey { 76 | typealias Value = Bool 77 | static var defaultValue: Bool { false } 78 | } 79 | 80 | // Extend CellState 81 | extension CellState { 82 | var isArchived: Bool { 83 | get { self[IsArchivedKey.self] } 84 | set { self[IsArchivedKey.self] = newValue } 85 | } 86 | } 87 | ``` 88 | 89 | ### 2. CollectionView - Pure SwiftUI Layouts 90 | 91 | Pure SwiftUI implementation using native components like ScrollView with Lazy stacks. **Not** based on UICollectionView. 92 | 93 | #### Layout Options: 94 | - `.list`: ScrollView with LazyVStack/LazyHStack 95 | - `.grid(...)`: ScrollView with LazyVGrid/LazyHGrid 96 | - `.platformList`: Native SwiftUI List 97 | 98 | #### Basic List Layout: 99 | 100 | ```swift 101 | import CollectionView 102 | import SwiftUI 103 | 104 | struct ContentView: View { 105 | var body: some View { 106 | CollectionView(layout: .list) { 107 | ForEach(items) { item in 108 | ItemView(item: item) 109 | } 110 | } 111 | } 112 | } 113 | ``` 114 | 115 | #### Grid Layout: 116 | 117 | ```swift 118 | CollectionView( 119 | layout: .grid( 120 | gridItems: [ 121 | GridItem(.flexible()), 122 | GridItem(.flexible()), 123 | GridItem(.flexible()) 124 | ], 125 | direction: .vertical, 126 | spacing: 8 127 | ) 128 | ) { 129 | ForEach(items) { item in 130 | GridItemView(item: item) 131 | } 132 | } 133 | ``` 134 | 135 | #### Infinite Scrolling with ScrollTracking: 136 | 137 | ```swift 138 | struct InfiniteFeedView: View { 139 | @State private var items: [FeedItem] = [] 140 | @State private var isLoading = false 141 | @State private var hasError = false 142 | @State private var currentPage = 1 143 | 144 | var body: some View { 145 | CollectionView(layout: .list) { 146 | ForEach(items) { item in 147 | FeedItemView(item: item) 148 | } 149 | 150 | // Loading indicator at the bottom 151 | if isLoading { 152 | HStack { 153 | ProgressView() 154 | Text("Loading more...") 155 | } 156 | .frame(maxWidth: .infinity) 157 | .padding() 158 | } 159 | } 160 | .onAdditionalLoading( 161 | isEnabled: !hasError, // Disable when there's an error 162 | leadingScreens: 1.5, // Trigger 1.5 screens before the end 163 | isLoading: $isLoading, 164 | onLoad: { 165 | await loadMoreItems() 166 | } 167 | ) 168 | .onAppear { 169 | if items.isEmpty { 170 | Task { 171 | await loadMoreItems() 172 | } 173 | } 174 | } 175 | } 176 | 177 | private func loadMoreItems() async { 178 | do { 179 | let newItems = try await APIClient.fetchFeedItems(page: currentPage) 180 | if !newItems.isEmpty { 181 | items.append(contentsOf: newItems) 182 | currentPage += 1 183 | hasError = false 184 | } 185 | } catch { 186 | hasError = true 187 | print("Failed to load items: \(error)") 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | #### Advanced Integration Example: 194 | 195 | ```swift 196 | struct AdvancedCollectionView: View { 197 | @State private var items: [Item] = [] 198 | @State private var selectedItems: Set = [] 199 | @State private var isLoading = false 200 | 201 | var body: some View { 202 | CollectionView( 203 | layout: .grid( 204 | gridItems: Array(repeating: GridItem(.flexible()), count: 2), 205 | direction: .vertical, 206 | spacing: 8 207 | ) 208 | ) { 209 | // Header with refresh control (from PullingControl module) 210 | RefreshControl( 211 | threshold: 60, 212 | action: { 213 | await refreshAllItems() 214 | } 215 | ) { context in 216 | // Custom refresh indicator 217 | } 218 | 219 | // Selectable items with infinite scrolling 220 | SelectableForEach( 221 | data: items, 222 | selection: .multiple( 223 | selected: selectedItems, 224 | canSelectMore: selectedItems.count < 10, 225 | onChange: handleSelection 226 | ) 227 | ) { index, item in 228 | GridItemView(item: item) 229 | } 230 | } 231 | .onAdditionalLoading( 232 | isLoading: $isLoading, 233 | onLoad: { 234 | await loadMoreItems() 235 | } 236 | ) 237 | } 238 | 239 | private func handleSelection(_ item: Item.ID, _ action: SelectAction) { 240 | switch action { 241 | case .selected: 242 | selectedItems.insert(item) 243 | case .deselected: 244 | selectedItems.remove(item) 245 | } 246 | } 247 | } 248 | 249 | ### 3. SelectableForEach 250 | 251 | A ForEach alternative that adds selection capabilities with environment values for selection state. Works with any container view - List, ScrollView, VStack, or custom containers. 252 | 253 | #### Single Selection: 254 | 255 | ```swift 256 | import SelectableForEach 257 | 258 | struct SelectableListView: View { 259 | @State private var selectedItem: Item.ID? 260 | 261 | var body: some View { 262 | List { 263 | SelectableForEach( 264 | data: items, 265 | selection: .single( 266 | selected: selectedItem, 267 | onChange: { newSelection in 268 | selectedItem = newSelection 269 | } 270 | ) 271 | ) { index, item in 272 | ItemCell(item: item) 273 | } 274 | } 275 | } 276 | } 277 | ``` 278 | 279 | #### Multiple Selection: 280 | 281 | ```swift 282 | struct MultiSelectListView: View { 283 | @State private var selectedItems = Set() 284 | 285 | var body: some View { 286 | ScrollView { 287 | LazyVStack { 288 | SelectableForEach( 289 | data: items, 290 | selection: .multiple( 291 | selected: selectedItems, 292 | canSelectMore: true, 293 | onChange: { selectedItem, action in 294 | switch action { 295 | case .selected: 296 | selectedItems.insert(selectedItem) 297 | case .deselected: 298 | selectedItems.remove(selectedItem) 299 | } 300 | } 301 | ) 302 | ) { index, item in 303 | ItemCell(item: item) 304 | } 305 | } 306 | } 307 | } 308 | } 309 | ``` 310 | 311 | #### Accessing Selection State in Child Views: 312 | 313 | ```swift 314 | struct ItemCell: View { 315 | let item: Item 316 | @Environment(\.selectableForEach_isSelected) var isSelected 317 | @Environment(\.selectableForEach_updateSelection) var updateSelection 318 | 319 | var body: some View { 320 | HStack { 321 | Text(item.title) 322 | Spacer() 323 | if isSelected { 324 | Image(systemName: "checkmark") 325 | } 326 | } 327 | .contentShape(Rectangle()) 328 | .onTapGesture { 329 | updateSelection(!isSelected) 330 | } 331 | .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) 332 | } 333 | } 334 | ``` 335 | 336 | #### Usage with Any Container: 337 | 338 | ```swift 339 | // With native SwiftUI List 340 | List { 341 | SelectableForEach(data: items, selection: selection) { index, item in 342 | ItemRow(item: item) 343 | } 344 | } 345 | 346 | // With VStack 347 | VStack { 348 | SelectableForEach(data: items, selection: selection) { index, item in 349 | ItemCard(item: item) 350 | } 351 | } 352 | 353 | // With CollectionView 354 | CollectionView(layout: .grid(...)) { 355 | SelectableForEach(data: items, selection: selection) { index, item in 356 | GridItem(item: item) 357 | } 358 | } 359 | ``` 360 | 361 | ### 4. ScrollTracking 362 | 363 | Provides infinite scrolling (additional loading) functionality for SwiftUI ScrollView and List views. Automatically triggers loading when the user approaches the end of scrollable content. 364 | 365 | ```swift 366 | import ScrollTracking 367 | 368 | struct InfiniteScrollView: View { 369 | @State private var items = Array(0..<20) 370 | @State private var isLoading = false 371 | 372 | var body: some View { 373 | ScrollView { 374 | LazyVStack { 375 | ForEach(items, id: \.self) { index in 376 | Text("Item \(index)") 377 | .frame(height: 50) 378 | } 379 | 380 | if isLoading { 381 | ProgressView() 382 | .frame(height: 50) 383 | } 384 | } 385 | } 386 | .onAdditionalLoading( 387 | leadingScreens: 2, // Trigger when 2 screen heights from bottom 388 | isLoading: $isLoading, 389 | onLoad: { 390 | // This runs automatically when user scrolls near the end 391 | let lastItem = items.last ?? -1 392 | let newItems = Array((lastItem + 1)..<(lastItem + 20)) 393 | items.append(contentsOf: newItems) 394 | } 395 | ) 396 | } 397 | } 398 | 399 | // Also works with List 400 | struct InfiniteList: View { 401 | @State private var items = Array(0..<20) 402 | @State private var isLoading = false 403 | 404 | var body: some View { 405 | List(items, id: \.self) { index in 406 | Text("Item \(index)") 407 | } 408 | .onAdditionalLoading( 409 | isLoading: $isLoading, 410 | onLoad: { 411 | let lastItem = items.last ?? -1 412 | let newItems = Array((lastItem + 1)..<(lastItem + 20)) 413 | items.append(contentsOf: newItems) 414 | } 415 | ) 416 | } 417 | } 418 | 419 | // Manual loading state management variant 420 | struct ManualInfiniteScrollView: View { 421 | @State private var items = Array(0..<20) 422 | @State private var isLoading = false 423 | 424 | var body: some View { 425 | ScrollView { 426 | LazyVStack { 427 | ForEach(items, id: \.self) { index in 428 | Text("Item \(index)") 429 | .frame(height: 50) 430 | } 431 | } 432 | } 433 | .onAdditionalLoading( 434 | isLoading: isLoading, // Pass current value (not binding) 435 | onLoad: { 436 | guard !isLoading else { return } 437 | isLoading = true 438 | Task { 439 | // Your async loading logic here 440 | defer { isLoading = false } 441 | let newItems = await fetchMoreItems() 442 | items.append(contentsOf: newItems) 443 | } 444 | } 445 | ) 446 | } 447 | } 448 | ``` 449 | 450 | #### Parameters: 451 | - `isEnabled`: Toggles the behavior on/off (default: true) 452 | - `leadingScreens`: Trigger threshold in multiples of screen height (default: 2) 453 | - `isLoading`: Loading state binding or current value 454 | - `onLoad`: Closure executed when loading should occur 455 | 456 | #### Features: 457 | - Works with both `ScrollView` and `List` 458 | - Automatic loading state management with binding variant 459 | - Manual loading state management with non-binding variant 460 | - Configurable trigger threshold 461 | - Prevents duplicate loads while one is in progress 462 | - Handles small content (triggers immediately if content is smaller than viewport) 463 | 464 | ### 5. StickyHeader 465 | 466 | Implements sticky header behavior with stretching effect for ScrollView. 467 | 468 | ```swift 469 | import StickyHeader 470 | 471 | struct StickyHeaderView: View { 472 | var body: some View { 473 | ScrollView { 474 | StickyHeader(sizing: .content) { context in 475 | VStack { 476 | Image("header-image") 477 | .resizable() 478 | .aspectRatio(contentMode: .fill) 479 | .frame(height: 200 + context.stretchingValue) 480 | 481 | Text("Stretching: \(context.phase == .stretching ? "Yes" : "No")") 482 | .padding() 483 | } 484 | } 485 | 486 | LazyVStack { 487 | ForEach(items) { item in 488 | ItemView(item: item) 489 | } 490 | } 491 | } 492 | } 493 | } 494 | ``` 495 | 496 | #### Fixed Height Header: 497 | 498 | ```swift 499 | StickyHeader(sizing: .fixed(250)) { context in 500 | HeaderContent() 501 | .scaleEffect(1 + context.stretchingValue / 100) 502 | } 503 | ``` 504 | 505 | ### 6. PullingControl 506 | 507 | A two-layered pull gesture detection system for ScrollView. Provides both low-level pull detection (`PullingControl`) and high-level pull-to-refresh functionality (`RefreshControl`). 508 | 509 | #### Low-Level: PullingControl 510 | 511 | Detects pull gestures and provides pull distance, progress, and threshold state. Use this when you need custom pull-based interactions beyond refresh. 512 | 513 | ```swift 514 | import PullingControl 515 | 516 | struct CustomPullView: View { 517 | @State private var log: [String] = [] 518 | 519 | var body: some View { 520 | ScrollView { 521 | VStack(spacing: 0) { 522 | PullingControl( 523 | threshold: 80, 524 | onChange: { context in 525 | // Called when pull state changes 526 | if context.isThresholdReached { 527 | log.append("Threshold reached!") 528 | } 529 | } 530 | ) { context in 531 | if context.isPulling { 532 | VStack { 533 | Text("Pull progress: \(Int(context.progress * 100))%") 534 | Text(context.isThresholdReached ? "Release!" : "Keep pulling") 535 | } 536 | .padding() 537 | } 538 | } 539 | 540 | LazyVStack { 541 | ForEach(log, id: \.self) { message in 542 | Text(message) 543 | } 544 | } 545 | } 546 | } 547 | } 548 | } 549 | ``` 550 | 551 | **PullingContext Properties:** 552 | - `pullDistance: CGFloat` - Current pull distance in points 553 | - `progress: Double` - Normalized progress (0.0 to 1.0) 554 | - `isThresholdReached: Bool` - Whether threshold is reached 555 | - `isPulling: Bool` - Whether currently pulling 556 | 557 | #### High-Level: RefreshControl 558 | 559 | Built on top of `PullingControl` with async action execution, refreshing state management, and haptic feedback. 560 | 561 | ```swift 562 | import PullingControl 563 | 564 | struct RefreshableList: View { 565 | @State private var items: [Item] = [] 566 | 567 | var body: some View { 568 | ScrollView { 569 | VStack(spacing: 0) { 570 | RefreshControl( 571 | threshold: 80, 572 | action: { 573 | await refreshData() 574 | } 575 | ) { context in 576 | VStack { 577 | switch context.state { 578 | case .pulling(let progress): 579 | Image(systemName: "arrow.down") 580 | .rotationEffect(.degrees(progress * 180)) 581 | if progress >= 1.0 { 582 | Text("Release to refresh") 583 | } 584 | case .refreshing: 585 | ProgressView() 586 | Text("Refreshing...") 587 | default: 588 | EmptyView() 589 | } 590 | } 591 | .padding() 592 | } 593 | 594 | LazyVStack { 595 | ForEach(items) { item in 596 | ItemView(item: item) 597 | } 598 | } 599 | } 600 | } 601 | } 602 | 603 | func refreshData() async { 604 | // Fetch new data 605 | try? await Task.sleep(for: .seconds(1)) 606 | items = await fetchLatestItems() 607 | } 608 | } 609 | ``` 610 | 611 | **RefreshControlContext States:** 612 | - `.idle` - Not pulling 613 | - `.pulling(progress: Double)` - User is pulling 614 | - `.refreshing` - Refresh action is executing 615 | - `.finishing` - Refresh completed (optional for animations) 616 | 617 | **When to Use Which:** 618 | - **PullingControl**: Custom pull-based interactions, analytics, custom state management 619 | - **RefreshControl**: Standard pull-to-refresh functionality 620 | 621 | ## Architecture Comparison 622 | 623 | | Module | Implementation | Use Case | 624 | |--------|---------------|----------| 625 | | **DynamicList** | UIKit UICollectionView with SwiftUI hosting | Maximum performance, large datasets, complex layouts | 626 | | **CollectionView** | Pure SwiftUI (ScrollView + Lazy stacks) | Simple layouts, moderate datasets, pure SwiftUI apps | 627 | | **SelectableForEach** | Pure SwiftUI with environment values | Add selection to any container view | 628 | | **ScrollTracking** | SwiftUI with introspection | Infinite scrolling, additional content loading | 629 | | **StickyHeader** | Pure SwiftUI with geometry tracking | Sticky headers with stretching effects | 630 | | **PullingControl** | Pure SwiftUI with geometry tracking | Pull-to-refresh and custom pull gestures | 631 | 632 | 633 | 634 | ## License 635 | 636 | Licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for more info. 637 | 638 | ## Author 639 | 640 | [FluidGroup](https://github.com/FluidGroup) -------------------------------------------------------------------------------- /Sources/ScrollTracking/ScrollTracking.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | import SwiftUIIntrospect 4 | import os.lock 5 | 6 | extension View { 7 | 8 | @_spi(Internal) 9 | public func onAdditionalLoading( 10 | additionalLoading: AdditionalLoading 11 | ) -> some View { 12 | 13 | modifier( 14 | _Modifier( 15 | additionalLoading: additionalLoading 16 | ) 17 | ) 18 | 19 | } 20 | } 21 | 22 | extension ScrollView { 23 | 24 | /// Attaches an infinite-scrolling style loader that calls your async closure 25 | /// as the user approaches the end of the scrollable content. 26 | /// 27 | /// Use this modifier to automatically request and append more data to a 28 | /// `ScrollView` when the user scrolls near the end. The modifier observes 29 | /// the scroll position and triggers `onLoad` once the remaining distance to 30 | /// the end is within `leadingScreens` times the visible length. If the content 31 | /// is initially smaller than the viewport, the loader triggers immediately. 32 | /// 33 | /// The `isLoading` binding is managed for you: it is set to `true` just before 34 | /// `onLoad` runs and reset to `false` when it finishes. While `isLoading` is 35 | /// `true`, additional triggers are suppressed. Only one load task runs at a 36 | /// time, and subsequent triggers are slightly debounced to avoid rapid 37 | /// re-invocation when the user hovers near the threshold. 38 | /// 39 | /// - Parameters: 40 | /// - isEnabled: Toggles the behavior on or off. When `false`, no loading is 41 | /// triggered. Default is `true`. 42 | /// - leadingScreens: The prefetch threshold expressed in multiples of the 43 | /// visible scrollable length (height for vertical, width for horizontal). 44 | /// For example, `2` triggers when the user is within two screenfuls of the end. 45 | /// Default is `2`. 46 | /// - axis: The scroll axis to monitor. Use `.vertical` for vertical scrolling 47 | /// or `.horizontal` for horizontal scrolling. Default is `.vertical`. 48 | /// - isLoading: A binding that reflects the current loading state. This 49 | /// modifier sets it to `true` before calling `onLoad` and back to `false` 50 | /// when `onLoad` completes. 51 | /// - onLoad: An async closure executed on the main actor when the threshold 52 | /// is crossed. Perform your data fetch and append logic here. 53 | /// 54 | /// - Returns: A view that monitors scrolling and triggers `onLoad` according to 55 | /// the provided parameters. 56 | /// 57 | /// - Important: Avoid starting additional loads inside `onLoad` while 58 | /// `isLoading` is `true`. The modifier already prevents re-entrancy by 59 | /// tracking the current load task and debouncing subsequent triggers. 60 | /// 61 | /// - Note: 62 | /// - If the content length is smaller than the viewport, `onLoad` is 63 | /// triggered once on appear so you can fetch enough items to fill the 64 | /// screen. 65 | /// - Use non-negative values for `leadingScreens`. Values near `0` trigger 66 | /// close to the end; larger values prefetch earlier. 67 | /// 68 | /// - SeeAlso: 69 | /// - ``onAdditionalLoading(isEnabled:leadingScreens:isLoading:onLoad:)`` on ``List`` 70 | /// - ``onAdditionalLoading(isEnabled:leadingScreens:isLoading:onLoad:)`` (non-binding overload) 71 | /// 72 | /// - Platform: 73 | /// - On iOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2 or later, 74 | /// the modifier uses SwiftUI scroll geometry to observe position. 75 | /// - On earlier supported iOS versions, it relies on scroll view introspection 76 | /// to observe content offset. 77 | @ViewBuilder 78 | public func onAdditionalLoading( 79 | isEnabled: Bool = true, 80 | leadingScreens: Double = 2, 81 | axis: Axis = .vertical, 82 | isLoading: Binding, 83 | onLoad: @escaping @MainActor () async -> Void 84 | ) -> some View { 85 | 86 | modifier( 87 | _Modifier( 88 | additionalLoading: .init( 89 | isEnabled: isEnabled, 90 | leadingScreens: leadingScreens, 91 | isLoading: isLoading, 92 | axis: axis, 93 | onLoad: onLoad 94 | ) 95 | ) 96 | ) 97 | 98 | } 99 | 100 | /// Adds "infinite scrolling" behavior to a ScrollView by invoking a closure when the user approaches 101 | /// the end of the scrollable content. 102 | /// 103 | /// This overload is designed for callers who manage their own loading state and perform a synchronous 104 | /// action on the main actor. If you prefer the modifier to drive the loading state for you and to 105 | /// support asynchronous loading, use the variant that takes a `Binding` and an `async` closure. 106 | /// 107 | /// Behavior 108 | /// - When the remaining distance to the end of the content becomes less than or equal to 109 | /// `leadingScreens * viewportLength`, `onLoad` is called. 110 | /// - If the content is smaller than the viewport, `onLoad` is also called (to allow initial prefetch). 111 | /// - Triggers are suppressed while `isEnabled` is `false`, while `isLoading` is `true`, and while a 112 | /// previous load triggered by this modifier is still in progress. A brief delay is applied after 113 | /// completion to avoid rapid duplicate triggers. 114 | /// - `onLoad` is executed on the main actor. Move heavy work off the main actor or use the async/Binding 115 | /// overload if you need structured concurrency. 116 | /// 117 | /// Platform availability 118 | /// - iOS 15.0+ 119 | /// - macOS 15.0+ 120 | /// - tvOS 18.0+ 121 | /// - watchOS 11.0+ 122 | /// - visionOS 2.0+ 123 | /// 124 | /// Parameters 125 | /// - isEnabled: Toggles additional-loading behavior on or off. Defaults to `true`. 126 | /// - leadingScreens: The prefetch threshold expressed in multiples of the current viewport length 127 | /// (height for vertical, width for horizontal). For example, a value of `2` triggers when the user 128 | /// is within two screen-lengths of the end. Use `0` to trigger only when reaching the very end. 129 | /// Prefer non-negative values. Defaults to `2`. 130 | /// - axis: The scroll axis to monitor. Use `.vertical` for vertical scrolling or `.horizontal` for 131 | /// horizontal scrolling. Default is `.vertical`. 132 | /// - isLoading: Your current loading state. While this is `true`, no new loads will be triggered. 133 | /// Note: This overload does not mutate your loading state; you must update it yourself in 134 | /// response to `onLoad`. If you want automatic state management, use the overload that takes 135 | /// a `Binding`. 136 | /// - onLoad: A closure executed on the main actor when prefetch should occur. This closure is 137 | /// synchronous; if you need to perform asynchronous work, start a `Task` inside the closure 138 | /// or use the async/Binding overload. 139 | /// 140 | /// Returns 141 | /// - A view that triggers `onLoad` when the user scrolls near the end of the content. 142 | /// 143 | /// See also 144 | /// - `onAdditionalLoading(isEnabled:leadingScreens:axis:isLoading:onLoad:)` where `isLoading` is a 145 | /// `Binding` and `onLoad` is `async`, which automatically toggles the loading state for you. 146 | /// 147 | /// Example 148 | /// ```swift 149 | /// struct FeedView: View { 150 | /// @State private var items: [Item] = [] 151 | /// @State private var isLoading = false 152 | /// 153 | /// var body: some View { 154 | /// ScrollView { 155 | /// LazyVStack { 156 | /// ForEach(items) { item in 157 | /// Row(item: item) 158 | /// } 159 | /// } 160 | /// } 161 | /// .onAdditionalLoading( 162 | /// isEnabled: true, 163 | /// leadingScreens: 1, 164 | /// axis: .vertical, 165 | /// isLoading: isLoading // pass the current value 166 | /// ) { 167 | /// // This closure runs on the main actor and is synchronous. 168 | /// // Manage your own loading state and async work: 169 | /// guard !isLoading else { return } 170 | /// isLoading = true 171 | /// Task { 172 | /// defer { await MainActor.run { isLoading = false } } 173 | /// let more = await fetchMoreItems() 174 | /// await MainActor.run { items.append(contentsOf: more) } 175 | /// } 176 | /// } 177 | /// } 178 | /// } 179 | /// ``` 180 | @ViewBuilder 181 | public func onAdditionalLoading( 182 | isEnabled: Bool = true, 183 | leadingScreens: Double = 2, 184 | axis: Axis = .vertical, 185 | isLoading: Bool, 186 | onLoad: @escaping @MainActor () -> Void 187 | ) -> some View { 188 | 189 | modifier( 190 | _Modifier( 191 | additionalLoading: .init( 192 | isEnabled: isEnabled, 193 | leadingScreens: leadingScreens, 194 | isLoading: isLoading, 195 | axis: axis, 196 | onLoad: onLoad 197 | ) 198 | ) 199 | ) 200 | 201 | } 202 | 203 | } 204 | 205 | extension List { 206 | @ViewBuilder 207 | public func onAdditionalLoading( 208 | isEnabled: Bool = true, 209 | leadingScreens: Double = 2, 210 | axis: Axis = .vertical, 211 | isLoading: Binding, 212 | onLoad: @escaping @MainActor () async -> Void 213 | ) -> some View { 214 | 215 | modifier( 216 | _Modifier( 217 | additionalLoading: .init( 218 | isEnabled: isEnabled, 219 | leadingScreens: leadingScreens, 220 | isLoading: isLoading, 221 | axis: axis, 222 | onLoad: onLoad 223 | ) 224 | ) 225 | ) 226 | } 227 | } 228 | 229 | public struct AdditionalLoading: Sendable { 230 | 231 | public let isEnabled: Bool 232 | public let leadingScreens: Double 233 | public let isLoading: Bool 234 | public let axis: Axis 235 | public let onLoad: @MainActor () async -> Void 236 | 237 | public init( 238 | isEnabled: Bool, 239 | leadingScreens: Double, 240 | isLoading: Binding, 241 | axis: Axis = .vertical, 242 | onLoad: @escaping @MainActor () async -> Void 243 | ) { 244 | self.isEnabled = isEnabled 245 | self.leadingScreens = leadingScreens 246 | self.isLoading = isLoading.wrappedValue 247 | self.axis = axis 248 | self.onLoad = { 249 | isLoading.wrappedValue = true 250 | await onLoad() 251 | isLoading.wrappedValue = false 252 | } 253 | } 254 | 255 | public init( 256 | isEnabled: Bool, 257 | leadingScreens: Double, 258 | isLoading: Bool, 259 | axis: Axis = .vertical, 260 | onLoad: @escaping @MainActor () -> Void 261 | ) { 262 | self.isEnabled = isEnabled 263 | self.leadingScreens = leadingScreens 264 | self.isLoading = isLoading 265 | self.axis = axis 266 | self.onLoad = onLoad 267 | } 268 | 269 | } 270 | 271 | @MainActor 272 | private final class Controller: ObservableObject { 273 | 274 | var scrollViewSubscription: AnyCancellable? = nil 275 | var currentLoadingTask: Task? = nil 276 | 277 | nonisolated init() {} 278 | } 279 | 280 | @available(iOS 15.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 281 | private struct _Modifier: ViewModifier { 282 | 283 | @StateObject var controller: Controller = .init() 284 | 285 | private let additionalLoading: AdditionalLoading 286 | 287 | nonisolated init( 288 | additionalLoading: AdditionalLoading 289 | ) { 290 | self.additionalLoading = additionalLoading 291 | } 292 | 293 | func body(content: Content) -> some View { 294 | 295 | if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, 296 | *) { 297 | content.onScrollGeometryChange(for: ScrollGeometry.self) { geometry in 298 | 299 | return geometry 300 | 301 | } action: { _, geometry in 302 | let triggers: Bool 303 | 304 | switch additionalLoading.axis { 305 | case .vertical: 306 | triggers = calculate( 307 | contentOffset: geometry.contentOffset.y, 308 | boundsLength: geometry.containerSize.height, 309 | contentSizeLength: geometry.contentSize.height, 310 | leadingScreens: additionalLoading.leadingScreens 311 | ) 312 | case .horizontal: 313 | triggers = calculate( 314 | contentOffset: geometry.contentOffset.x, 315 | boundsLength: geometry.containerSize.width, 316 | contentSizeLength: geometry.contentSize.width, 317 | leadingScreens: additionalLoading.leadingScreens 318 | ) 319 | } 320 | 321 | if triggers { 322 | Task { @MainActor in 323 | trigger() 324 | } 325 | } 326 | 327 | } 328 | } else { 329 | 330 | #if canImport(UIKit) 331 | 332 | content.introspect(.scrollView, on: .iOS(.v15, .v16, .v17)) { 333 | scrollView in 334 | 335 | controller.scrollViewSubscription?.cancel() 336 | 337 | controller.scrollViewSubscription = scrollView.publisher( 338 | for: \.contentOffset 339 | ).sink { 340 | [weak scrollView] offset in 341 | 342 | guard let scrollView else { 343 | return 344 | } 345 | 346 | let triggers: Bool 347 | 348 | switch additionalLoading.axis { 349 | case .vertical: 350 | triggers = calculate( 351 | contentOffset: offset.y, 352 | boundsLength: scrollView.bounds.height, 353 | contentSizeLength: scrollView.contentSize.height, 354 | leadingScreens: additionalLoading.leadingScreens 355 | ) 356 | case .horizontal: 357 | triggers = calculate( 358 | contentOffset: offset.x, 359 | boundsLength: scrollView.bounds.width, 360 | contentSizeLength: scrollView.contentSize.width, 361 | leadingScreens: additionalLoading.leadingScreens 362 | ) 363 | } 364 | 365 | if triggers { 366 | Task { @MainActor in 367 | trigger() 368 | } 369 | } 370 | 371 | } 372 | } 373 | #else 374 | fatalError() 375 | #endif 376 | } 377 | } 378 | 379 | @MainActor 380 | private func trigger() { 381 | 382 | guard additionalLoading.isEnabled else { 383 | return 384 | } 385 | 386 | guard additionalLoading.isLoading == false else { 387 | return 388 | } 389 | 390 | guard controller.currentLoadingTask == nil else { 391 | return 392 | } 393 | 394 | let task = Task { @MainActor in 395 | await withTaskCancellationHandler { 396 | await additionalLoading.onLoad() 397 | controller.currentLoadingTask = nil 398 | } onCancel: { 399 | Task { @MainActor in 400 | controller.currentLoadingTask = nil 401 | } 402 | } 403 | 404 | // easiest way to avoid multiple triggers 405 | try? await Task.sleep(for: .seconds(0.1)) 406 | } 407 | 408 | controller.currentLoadingTask = task 409 | 410 | } 411 | 412 | } 413 | 414 | private func calculate( 415 | contentOffset: CGFloat, 416 | boundsLength: CGFloat, 417 | contentSizeLength: CGFloat, 418 | leadingScreens: CGFloat 419 | ) -> Bool { 420 | 421 | guard leadingScreens > 0 || boundsLength != .zero else { 422 | return false 423 | } 424 | 425 | let viewLength = boundsLength 426 | let offset = contentOffset 427 | let contentLength = contentSizeLength 428 | 429 | let hasSmallContent = (offset == 0.0) && (contentLength < viewLength) 430 | 431 | let triggerDistance = viewLength * leadingScreens 432 | let remainingDistance = contentLength - viewLength - offset 433 | 434 | return (hasSmallContent || remainingDistance <= triggerDistance) 435 | } 436 | 437 | @available(iOS 17, *) 438 | #Preview { 439 | @Previewable @State var items = Array(0..<20) 440 | @Previewable @State var isLoading = false 441 | 442 | List(items, id: \.self) { index in 443 | Text("Item \(index)") 444 | .frame(height: 50) 445 | .background(Color.red) 446 | } 447 | .onAdditionalLoading( 448 | isLoading: $isLoading, 449 | onLoad: { 450 | try? await Task.sleep(for: .seconds(1)) 451 | let lastItem = items.last ?? -1 452 | let newItems = Array((lastItem + 1)..<(lastItem + 21)) 453 | items.append(contentsOf: newItems) 454 | } 455 | ) 456 | .onAppear { 457 | print("Hello") 458 | } 459 | } 460 | 461 | @available(iOS 17, *) 462 | #Preview("ScrollView") { 463 | @Previewable @State var items = Array(0..<20) 464 | @Previewable @State var isLoading = false 465 | 466 | ScrollView { 467 | LazyVStack { 468 | Section { 469 | ForEach(items, id: \.self) { index in 470 | Text("Item \(index)") 471 | .frame(height: 50) 472 | .background(Color.blue) 473 | } 474 | } footer: { 475 | if isLoading { 476 | ProgressView() 477 | .frame(height: 50) 478 | } 479 | } 480 | } 481 | } 482 | .onAdditionalLoading( 483 | isLoading: $isLoading, 484 | onLoad: { 485 | try? await Task.sleep(for: .seconds(1)) 486 | let lastItem = items.last ?? -1 487 | let newItems = Array((lastItem + 1)..<(lastItem + 21)) 488 | items.append(contentsOf: newItems) 489 | } 490 | ) 491 | .onAppear { 492 | print("Hello") 493 | } 494 | } 495 | 496 | @available(iOS 17, *) 497 | #Preview("ScrollView Non-Binding") { 498 | @Previewable @State var items = Array(0..<20) 499 | @Previewable @State var isLoading = false 500 | 501 | ScrollView { 502 | LazyVStack { 503 | 504 | Section { 505 | ForEach(items, id: \.self) { index in 506 | Text("Item \(index)") 507 | .frame(height: 50) 508 | .background(Color.blue) 509 | } 510 | } footer: { 511 | if isLoading { 512 | ProgressView() 513 | .frame(height: 50) 514 | } 515 | } 516 | 517 | } 518 | } 519 | .onAdditionalLoading( 520 | isLoading: isLoading, 521 | onLoad: { 522 | guard !isLoading else { 523 | print("Skip") 524 | return 525 | } 526 | isLoading = true 527 | print("Load triggered") 528 | Task { 529 | try? await Task.sleep(for: .seconds(1)) 530 | let lastItem = items.last ?? -1 531 | let newItems = Array((lastItem + 1)..<(lastItem + 21)) 532 | items.append(contentsOf: newItems) 533 | isLoading = false 534 | } 535 | } 536 | ) 537 | } 538 | 539 | @available(iOS 17, *) 540 | #Preview("Horizontal ScrollView") { 541 | @Previewable @State var items = Array(0..<20) 542 | @Previewable @State var isLoading = false 543 | 544 | ScrollView(.horizontal) { 545 | LazyHStack { 546 | Section { 547 | ForEach(items, id: \.self) { index in 548 | Text("Item \(index)") 549 | .frame(width: 100, height: 100) 550 | .background(Color.green) 551 | } 552 | } footer: { 553 | if isLoading { 554 | ProgressView() 555 | .frame(width: 50, height: 100) 556 | } 557 | } 558 | } 559 | } 560 | .onAdditionalLoading( 561 | leadingScreens: 1, 562 | axis: .horizontal, 563 | isLoading: $isLoading, 564 | onLoad: { 565 | try? await Task.sleep(for: .seconds(1)) 566 | let lastItem = items.last ?? -1 567 | let newItems = Array((lastItem + 1)..<(lastItem + 11)) 568 | items.append(contentsOf: newItems) 569 | } 570 | ) 571 | } 572 | -------------------------------------------------------------------------------- /Sources/DynamicList/DynamicListView.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import SwiftUI 3 | import UIKit 4 | 5 | /// A key using types that brings custom state into cell. 6 | /// 7 | /// ```swift 8 | /// enum IsArchivedKey: CustomStateKey { 9 | /// typealias Value = Bool 10 | /// 11 | /// static var defaultValue: Bool { false } 12 | /// } 13 | /// ``` 14 | /// 15 | /// ```swift 16 | /// extension CellState { 17 | /// var isArchived: Bool { 18 | /// get { self[IsArchivedKey.self] } 19 | /// set { self[IsArchivedKey.self] = newValue } 20 | /// } 21 | /// } 22 | /// ``` 23 | public protocol CustomStateKey { 24 | associatedtype Value 25 | 26 | static var defaultValue: Value { get } 27 | } 28 | 29 | /// Additional cell state storage. 30 | /// Refer `CustomStateKey` to use your own state for cell. 31 | public struct CellState { 32 | 33 | public static var empty: CellState { 34 | .init() 35 | } 36 | 37 | private var stateMap: [AnyKeyPath: Any] = [:] 38 | 39 | init() { 40 | 41 | } 42 | 43 | public subscript(key: T.Type) -> T.Value { 44 | get { 45 | stateMap[\T.self] as? T.Value ?? T.defaultValue 46 | } 47 | set { 48 | stateMap[\T.self] = newValue 49 | } 50 | } 51 | 52 | } 53 | 54 | public enum DynamicListViewScrollAction { 55 | case didScroll 56 | } 57 | 58 | /// Preimplemented list view using UICollectionView and UICollectionViewCompositionalLayout. 59 | /// - Supports dynamic content update 60 | /// - Self cell sizing 61 | /// - Update sizing using ``DynamicSizingCollectionViewCell``. 62 | /// 63 | /// - TODO: Currently supported only vertical scrolling. 64 | @available(iOS 13, *) 65 | public final class DynamicListView< 66 | Section: Hashable & Sendable, 67 | Data: Hashable & Sendable 68 | >: UIView, 69 | UICollectionViewDelegate, UIScrollViewDelegate 70 | { 71 | 72 | public enum SelectionAction { 73 | case didSelect(Data, IndexPath) 74 | case didDeselect(Data, IndexPath) 75 | } 76 | 77 | @MainActor 78 | public struct SupplementaryViewProviderContext { 79 | 80 | public var collectionView: UICollectionView { 81 | _collectionView 82 | } 83 | 84 | unowned let _collectionView: CollectionView 85 | 86 | public let indexPath: IndexPath 87 | public let kind: String 88 | 89 | } 90 | 91 | @MainActor 92 | public struct CellProviderContext { 93 | 94 | public var collectionView: UICollectionView { 95 | _collectionView 96 | } 97 | 98 | unowned let _collectionView: CollectionView 99 | 100 | public let data: Data 101 | public let indexPath: IndexPath 102 | private let cellState: CellState 103 | 104 | init( 105 | _collectionView: CollectionView, 106 | data: Data, 107 | indexPath: IndexPath, 108 | cellState: CellState 109 | ) { 110 | self._collectionView = _collectionView 111 | self.data = data 112 | self.indexPath = indexPath 113 | self.cellState = cellState 114 | } 115 | 116 | public func dequeueReusableCell( 117 | _ cellType: Cell.Type 118 | ) -> Cell { 119 | return _collectionView.dequeueReusableCell( 120 | withReuseIdentifier: _typeName(Cell.self), 121 | for: indexPath 122 | ) as! Cell 123 | } 124 | 125 | public func dequeueDefaultCell() -> VersatileCell { 126 | return _collectionView.dequeueReusableCell( 127 | withReuseIdentifier: "DynamicSizingCollectionViewCell", 128 | for: indexPath 129 | ) as! VersatileCell 130 | } 131 | 132 | public func cell( 133 | file: StaticString = #file, 134 | line: UInt = #line, 135 | column: UInt = #column, 136 | reuseIdentifier: String? = nil, 137 | withConfiguration contentConfiguration: 138 | @escaping @MainActor ( 139 | VersatileCell, UICellConfigurationState, CellState 140 | ) -> Configuration 141 | ) -> VersatileCell { 142 | 143 | let _reuseIdentifier = reuseIdentifier ?? "\(file):\(line):\(column)" 144 | 145 | if _collectionView.cellForIdentifiers.contains(_reuseIdentifier) 146 | == false 147 | { 148 | 149 | Log.generic.debug("Register Cell : \(_reuseIdentifier)") 150 | 151 | _collectionView.register( 152 | VersatileCell.self, 153 | forCellWithReuseIdentifier: _reuseIdentifier 154 | ) 155 | } 156 | 157 | let cell = 158 | _collectionView.dequeueReusableCell( 159 | withReuseIdentifier: _reuseIdentifier, 160 | for: indexPath 161 | ) as! VersatileCell 162 | 163 | cell.contentConfiguration = contentConfiguration( 164 | cell, 165 | cell.configurationState, 166 | cellState 167 | ) 168 | cell._updateConfigurationHandler = { cell, state, customState in 169 | cell.contentConfiguration = contentConfiguration( 170 | cell, 171 | state, 172 | customState 173 | ) 174 | } 175 | 176 | return cell 177 | 178 | } 179 | 180 | public func cell( 181 | file: StaticString = #file, 182 | line: UInt = #line, 183 | column: UInt = #column, 184 | reuseIdentifier: String? = nil, 185 | @ViewBuilder content: 186 | @escaping @MainActor (UICellConfigurationState, CellState) -> 187 | some View 188 | ) -> VersatileCell { 189 | 190 | return self.cell( 191 | file: file, 192 | line: line, 193 | column: column, 194 | reuseIdentifier: reuseIdentifier, 195 | withConfiguration: { cell, state, customState in 196 | UIHostingConfiguration { 197 | content(state, customState).environment(\.versatileCell, cell) 198 | }.margins( 199 | .all, 200 | 0 201 | ) 202 | } 203 | ) 204 | 205 | } 206 | 207 | } 208 | 209 | public var scrollView: UIScrollView { 210 | _collectionView 211 | } 212 | 213 | public var collectionView: UICollectionView { 214 | _collectionView 215 | } 216 | 217 | private let _collectionView: CollectionView 218 | 219 | public var layout: UICollectionViewLayout { 220 | _collectionView.collectionViewLayout 221 | } 222 | 223 | private var _cellProvider: ((CellProviderContext) -> UICollectionViewCell)? 224 | 225 | private var _selectionHandler: @MainActor (SelectionAction) -> Void = { _ in 226 | } 227 | private var _scrollHandler: 228 | @MainActor (UIScrollView, DynamicListViewScrollAction) -> Void = { _, _ in 229 | } 230 | private var _incrementalContentLoader: @MainActor () async throws -> Void = 231 | {} 232 | 233 | private var dataSource: UICollectionViewDiffableDataSource! 234 | 235 | // TODO: remove CellState following cell deletion. 236 | private var stateMap: [Data: CellState] = [:] 237 | 238 | private let contentPagingTrigger: ContentPagingTrigger 239 | 240 | public init( 241 | layout: UICollectionViewLayout, 242 | scrollDirection: UICollectionView.ScrollDirection, 243 | contentInsetAdjustmentBehavior: UIScrollView 244 | .ContentInsetAdjustmentBehavior = .automatic 245 | ) { 246 | 247 | self._collectionView = CollectionView.init( 248 | frame: .null, 249 | collectionViewLayout: layout 250 | ) 251 | self.contentPagingTrigger = .init( 252 | scrollView: _collectionView, 253 | trackingScrollDirection: { 254 | switch scrollDirection { 255 | case .vertical: 256 | return .down 257 | case .horizontal: 258 | return .right 259 | @unknown default: 260 | return .down 261 | } 262 | }(), 263 | leadingScreensForBatching: 1 264 | ) 265 | 266 | super.init(frame: .null) 267 | 268 | self.backgroundColor = .clear 269 | self._collectionView.backgroundColor = .clear 270 | self._collectionView.contentInsetAdjustmentBehavior = 271 | contentInsetAdjustmentBehavior 272 | 273 | self.addSubview(_collectionView) 274 | 275 | _collectionView.translatesAutoresizingMaskIntoConstraints = false 276 | 277 | NSLayoutConstraint.activate([ 278 | _collectionView.topAnchor.constraint(equalTo: topAnchor), 279 | _collectionView.rightAnchor.constraint(equalTo: rightAnchor), 280 | _collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), 281 | _collectionView.leftAnchor.constraint(equalTo: leftAnchor), 282 | ]) 283 | 284 | let dataSource = UICollectionViewDiffableDataSource( 285 | collectionView: collectionView, 286 | cellProvider: { [unowned self] collectionView, indexPath, item in 287 | 288 | guard let provider = self._cellProvider else { 289 | assertionFailure("Needs setup before start using.") 290 | return UICollectionViewCell(frame: .zero) 291 | } 292 | 293 | let data = item 294 | 295 | let state = stateMap[data] ?? .init() 296 | 297 | let context = CellProviderContext.init( 298 | _collectionView: collectionView as! CollectionView, 299 | data: data, 300 | indexPath: indexPath, 301 | cellState: state 302 | ) 303 | 304 | let cell = provider(context) 305 | 306 | if let versatileCell = cell as? VersatileCell { 307 | versatileCell.customState = state 308 | versatileCell.updateContent(using: state) 309 | } 310 | 311 | return cell 312 | 313 | } 314 | ) 315 | 316 | self.dataSource = dataSource 317 | 318 | self._collectionView.register( 319 | VersatileCell.self, 320 | forCellWithReuseIdentifier: "DynamicSizingCollectionViewCell" 321 | ) 322 | self._collectionView.delegate = self 323 | self.collectionView.dataSource = dataSource 324 | self._collectionView.delaysContentTouches = false 325 | // self.collectionView.isPrefetchingEnabled = false 326 | // self.collectionView.prefetchDataSource = nil 327 | 328 | #if swift(>=5.7) 329 | if #available(iOS 16.0, *) { 330 | assert(self._collectionView.selfSizingInvalidation == .enabled) 331 | } 332 | #endif 333 | 334 | } 335 | 336 | public convenience init( 337 | compositionalLayout: UICollectionViewCompositionalLayout, 338 | contentInsetAdjustmentBehavior: UIScrollView 339 | .ContentInsetAdjustmentBehavior = .automatic 340 | ) { 341 | self.init( 342 | layout: compositionalLayout, 343 | scrollDirection: compositionalLayout.configuration.scrollDirection, 344 | contentInsetAdjustmentBehavior: contentInsetAdjustmentBehavior 345 | ) 346 | } 347 | 348 | public convenience init( 349 | scrollDirection: UICollectionView.ScrollDirection, 350 | spacing: CGFloat = 0, 351 | contentInsetAdjustmentBehavior: UIScrollView 352 | .ContentInsetAdjustmentBehavior = .automatic 353 | ) { 354 | 355 | let layout: UICollectionViewCompositionalLayout 356 | 357 | switch scrollDirection { 358 | case .vertical: 359 | 360 | let group = NSCollectionLayoutGroup.vertical( 361 | layoutSize: NSCollectionLayoutSize( 362 | widthDimension: .fractionalWidth(1.0), 363 | heightDimension: .estimated(100) 364 | ), 365 | subitems: [ 366 | NSCollectionLayoutItem( 367 | layoutSize: NSCollectionLayoutSize( 368 | widthDimension: .fractionalWidth(1.0), 369 | heightDimension: .estimated(100) 370 | ) 371 | ) 372 | ] 373 | ) 374 | 375 | let section = NSCollectionLayoutSection(group: group) 376 | section.interGroupSpacing = spacing 377 | 378 | let configuration = UICollectionViewCompositionalLayoutConfiguration() 379 | configuration.scrollDirection = scrollDirection 380 | 381 | layout = UICollectionViewCompositionalLayout.init(section: section) 382 | 383 | case .horizontal: 384 | 385 | let group = NSCollectionLayoutGroup.horizontal( 386 | layoutSize: .init( 387 | widthDimension: .estimated(100), 388 | heightDimension: .fractionalHeight(1) 389 | ), 390 | subitems: [ 391 | .init( 392 | layoutSize: .init( 393 | widthDimension: .estimated(100), 394 | heightDimension: .fractionalHeight(1) 395 | ) 396 | ) 397 | ] 398 | ) 399 | 400 | let section = NSCollectionLayoutSection(group: group) 401 | section.interGroupSpacing = spacing 402 | 403 | let configuration = UICollectionViewCompositionalLayoutConfiguration() 404 | configuration.scrollDirection = scrollDirection 405 | 406 | layout = UICollectionViewCompositionalLayout.init( 407 | section: section, 408 | configuration: configuration 409 | ) 410 | 411 | @unknown default: 412 | fatalError() 413 | } 414 | 415 | self.init( 416 | layout: layout, 417 | scrollDirection: layout.configuration.scrollDirection, 418 | contentInsetAdjustmentBehavior: contentInsetAdjustmentBehavior 419 | ) 420 | 421 | } 422 | 423 | public 424 | required init?(coder: NSCoder) 425 | { 426 | fatalError("init(coder:) has not been implemented") 427 | } 428 | 429 | public func registerCell( 430 | _ cellType: Cell.Type 431 | ) { 432 | _collectionView.register( 433 | cellType, 434 | forCellWithReuseIdentifier: _typeName(Cell.self) 435 | ) 436 | } 437 | 438 | public func setUp( 439 | cellProvider: @escaping (CellProviderContext) -> UICollectionViewCell 440 | ) { 441 | _cellProvider = cellProvider 442 | } 443 | 444 | public func setAllowsMultipleSelection(_ allows: Bool) { 445 | _collectionView.allowsMultipleSelection = allows 446 | } 447 | 448 | public func resetState() { 449 | stateMap.removeAll() 450 | 451 | for cell in _collectionView.visibleCells { 452 | 453 | if let versatileCell = cell as? VersatileCell { 454 | versatileCell.customState = .empty 455 | versatileCell.updateContent(using: .empty) 456 | } 457 | 458 | } 459 | } 460 | 461 | public func state(for data: Data, key: Key.Type) -> Key 462 | .Value? 463 | { 464 | return stateMap[data]?[Key.self] as? Key.Value 465 | } 466 | 467 | func _setState(cellState: CellState, for data: Data) { 468 | guard let indexPath = dataSource.indexPath(for: data) else { 469 | return 470 | } 471 | 472 | stateMap[data] = cellState 473 | 474 | guard 475 | let cell = _collectionView.cellForItem(at: indexPath) as? VersatileCell 476 | else { 477 | return 478 | } 479 | 480 | cell.customState = cellState 481 | cell.updateContent(using: cellState) 482 | } 483 | 484 | @available(iOS 15.0, *) 485 | public func setState( 486 | _ value: Key.Value, 487 | key: Key.Type, 488 | for data: Data 489 | ) { 490 | 491 | guard let indexPath = dataSource.indexPath(for: data) else { 492 | return 493 | } 494 | 495 | var cellState = stateMap[data, default: .empty] 496 | cellState[Key.self] = value 497 | stateMap[data] = cellState 498 | 499 | guard 500 | let cell = _collectionView.cellForItem(at: indexPath) as? VersatileCell 501 | else { 502 | return 503 | } 504 | 505 | cell.customState = cellState 506 | cell.updateContent(using: cellState) 507 | 508 | } 509 | 510 | public func select( 511 | data: Data, 512 | animated: Bool, 513 | scrollPosition: UICollectionView.ScrollPosition 514 | ) { 515 | 516 | guard let indexPath = dataSource.indexPath(for: data) else { 517 | return 518 | } 519 | 520 | _collectionView.selectItem( 521 | at: indexPath, 522 | animated: animated, 523 | scrollPosition: scrollPosition 524 | ) 525 | 526 | } 527 | 528 | public func supplementaryViewHandler( 529 | _ handler: 530 | @escaping @MainActor (SupplementaryViewProviderContext) -> 531 | UICollectionReusableView 532 | ) { 533 | 534 | dataSource.supplementaryViewProvider = { 535 | collectionView, 536 | kind, 537 | indexPath in 538 | 539 | let context = SupplementaryViewProviderContext( 540 | _collectionView: collectionView as! CollectionView, 541 | indexPath: indexPath, 542 | kind: kind 543 | ) 544 | 545 | let view = handler(context) 546 | 547 | return view 548 | } 549 | 550 | } 551 | 552 | public func setIncrementalContentLoader( 553 | _ loader: @escaping @MainActor () async throws -> Void 554 | ) { 555 | _incrementalContentLoader = loader 556 | 557 | contentPagingTrigger.onBatchFetch = { [weak self] in 558 | guard let self = self else { return } 559 | do { 560 | try await self._incrementalContentLoader() 561 | } catch { 562 | 563 | } 564 | } 565 | 566 | } 567 | 568 | public func setScrollHandler( 569 | _ handler: 570 | @escaping @MainActor (UIScrollView, DynamicListViewScrollAction) -> Void 571 | ) { 572 | _scrollHandler = handler 573 | } 574 | 575 | public func setSelectionHandler( 576 | _ handler: @escaping @MainActor (SelectionAction) -> Void 577 | ) { 578 | _selectionHandler = handler 579 | } 580 | 581 | public func setContents( 582 | snapshot: NSDiffableDataSourceSnapshot, 583 | animatedUpdating: Bool = true 584 | ) { 585 | 586 | dataSource.apply(snapshot, animatingDifferences: animatedUpdating) 587 | 588 | } 589 | 590 | /** 591 | Displays cells with given contents. 592 | CollectionView will update its cells partially using DiffableDataSources. 593 | */ 594 | public func setContents(_ contents: [Data], animatedUpdating: Bool = true) 595 | where Section == DynamicCompositionalLayoutSingleSection { 596 | 597 | if #available(iOS 14, *) { 598 | 599 | } else { 600 | // fix crash 601 | // https://developer.apple.com/forums/thread/126742 602 | let currentSnapshot = self.dataSource.snapshot() 603 | if currentSnapshot.numberOfItems == 0, contents.isEmpty { 604 | return 605 | } 606 | } 607 | 608 | var newSnapshot = NSDiffableDataSourceSnapshot.init() 609 | newSnapshot.appendSections([.main]) 610 | newSnapshot.appendItems(contents, toSection: .main) 611 | 612 | setContents(snapshot: newSnapshot, animatedUpdating: animatedUpdating) 613 | 614 | } 615 | 616 | public func snapshot() -> NSDiffableDataSourceSnapshot { 617 | dataSource.snapshot() 618 | } 619 | 620 | public func setContents( 621 | _ contents: [Data], 622 | inSection section: Section, 623 | animatedUpdating: Bool = true 624 | ) { 625 | 626 | var snapshot = dataSource.snapshot() 627 | 628 | snapshot.deleteSections([section]) 629 | snapshot.appendSections([section]) 630 | snapshot.appendItems(contents, toSection: section) 631 | 632 | setContents(snapshot: snapshot, animatedUpdating: animatedUpdating) 633 | 634 | } 635 | 636 | public func setContentInset(_ insets: UIEdgeInsets) { 637 | _collectionView.contentInset = insets 638 | } 639 | 640 | public func scroll( 641 | to data: Data, 642 | at scrollPosition: UICollectionView.ScrollPosition, 643 | skipCondition: @escaping @MainActor (UIScrollView) -> Bool, 644 | animated: Bool 645 | ) { 646 | guard let indexPath = dataSource.indexPath(for: data) else { 647 | return 648 | } 649 | 650 | if skipCondition(_collectionView) { 651 | return 652 | } 653 | 654 | _collectionView.scrollToItem( 655 | at: indexPath, 656 | at: scrollPosition, 657 | animated: animated 658 | ) 659 | } 660 | 661 | // MARK: - UICollectionViewDelegate 662 | 663 | public func collectionView( 664 | _ collectionView: UICollectionView, 665 | didSelectItemAt indexPath: IndexPath 666 | ) { 667 | let item = dataSource.itemIdentifier(for: indexPath)! 668 | _selectionHandler(.didSelect(item, indexPath)) 669 | } 670 | 671 | public func collectionView( 672 | _ collectionView: UICollectionView, 673 | didDeselectItemAt indexPath: IndexPath 674 | ) { 675 | let item = dataSource.itemIdentifier(for: indexPath)! 676 | _selectionHandler(.didDeselect(item, indexPath)) 677 | } 678 | 679 | // MARK: - UIScrollViewDelegate 680 | 681 | public func scrollViewDidScroll(_ scrollView: UIScrollView) { 682 | _scrollHandler(collectionView, .didScroll) 683 | } 684 | 685 | } 686 | 687 | internal final class CollectionView: UICollectionView { 688 | 689 | fileprivate var cellForIdentifiers: Set = .init() 690 | 691 | override func register( 692 | _ cellClass: AnyClass?, 693 | forCellWithReuseIdentifier identifier: String 694 | ) { 695 | cellForIdentifiers.insert(identifier) 696 | super.register(cellClass, forCellWithReuseIdentifier: identifier) 697 | } 698 | } 699 | 700 | @available(iOS 13, *) 701 | public typealias DynamicCompositionalLayoutSingleSectionView = 702 | DynamicListView 703 | 704 | public enum DynamicCompositionalLayoutSingleSection: Hashable, Sendable { 705 | case main 706 | } 707 | 708 | private enum CellContextKey: EnvironmentKey { 709 | static var defaultValue: VersatileCell? { nil } 710 | } 711 | 712 | extension EnvironmentValues { 713 | public var versatileCell: VersatileCell? { 714 | get { self[CellContextKey.self] } 715 | set { self[CellContextKey.self] = newValue } 716 | } 717 | } 718 | 719 | #endif 720 | --------------------------------------------------------------------------------