├── .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 |
--------------------------------------------------------------------------------