├── .github
├── CODEOWNERS
├── workflows
│ ├── draft-release-note.yml
│ ├── ci-sampleapp.yml
│ └── ci.yml
└── release-drafter.yml
├── .gitignore
├── .spi.yml
├── Examples
└── KarrotListKitSampleApp
│ ├── KarrotListKitSampleApp.xcodeproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── swiftpm
│ │ │ └── Package.resolved
│ ├── xcshareddata
│ │ └── IDETemplateMacros.plist
│ └── project.pbxproj
│ └── KarrotListKitSampleApp
│ ├── SampleApp.swift
│ ├── RootView.swift
│ ├── Utilities
│ ├── String+Extensions.swift
│ └── UIViewRepresentation.swift
│ └── Samples
│ └── VerticalLayout
│ ├── VerticalLayoutItemComponent.swift
│ ├── VerticalLayoutListView.swift
│ └── VerticalLayoutItemView.swift
├── Sources
├── KarrotListKit
│ ├── Utils
│ │ ├── Collection+SafeIndex.swift
│ │ ├── Any+Equatable.swift
│ │ └── Chunked.swift
│ ├── FeatureFlag
│ │ ├── DefaultFeatureFlagProvider.swift
│ │ ├── FeatureFlagType.swift
│ │ ├── KarrotListKitFeatureFlag.swift
│ │ ├── FeatureFlagProviding.swift
│ │ └── FeatureFlagItem.swift
│ ├── Component
│ │ ├── IdentifiableComponent.swift
│ │ ├── ContentLayoutMode.swift
│ │ ├── Component.swift
│ │ └── AnyComponent.swift
│ ├── MacroInterface
│ │ └── AddComponentModifier.swift
│ ├── Event
│ │ ├── ListingViewEventStorage.swift
│ │ ├── ListingViewEvent.swift
│ │ ├── List
│ │ │ ├── PullToRefreshEvent.swift
│ │ │ ├── DidScrollEvent.swift
│ │ │ ├── ShouldScrollToTopEvent.swift
│ │ │ ├── DidScrollToTopEvent.swift
│ │ │ ├── WillBeginDraggingEvent.swift
│ │ │ ├── DidEndDeceleratingEvent.swift
│ │ │ ├── WillBeginDeceleratingEvent.swift
│ │ │ ├── DidEndDraggingEvent.swift
│ │ │ ├── WillEndDraggingEvent.swift
│ │ │ └── ReachedEndEvent.swift
│ │ ├── ListingViewEventHandler.swift
│ │ ├── Cell
│ │ │ ├── DidSelectEvent.swift
│ │ │ ├── HighlightEvent.swift
│ │ │ └── UnhighlightEvent.swift
│ │ └── Common
│ │ │ ├── WillDisplayEvent.swift
│ │ │ └── DidEndDisplayingEvent.swift
│ ├── Extension
│ │ ├── UICollectionView+Init.swift
│ │ ├── UIView+TraitCollection.swift .swift
│ │ └── UICollectionView+Difference.swift
│ ├── Prefetching
│ │ ├── PrefetchableComponent.swift
│ │ ├── RemoteImagePrefetching.swift
│ │ └── Plugins
│ │ │ ├── CollectionViewPrefetchingPlugin.swift
│ │ │ └── RemoteImagePrefetchingPlugin.swift
│ ├── Adapter
│ │ ├── CollectionViewAdapterUpdateStrategy.swift
│ │ ├── CollectionViewAdapterConfiguration.swift
│ │ ├── ComponentSizeStorage.swift
│ │ └── CollectionViewLayoutAdaptable.swift
│ ├── SwiftUISupport
│ │ └── ComponentRepresented.swift
│ ├── Builder
│ │ ├── SectionsBuilder.swift
│ │ └── CellsBuilder.swift
│ ├── View
│ │ ├── ComponentRenderable.swift
│ │ ├── UICollectionComponentReusableView.swift
│ │ └── UICollectionViewComponentCell.swift
│ ├── SupplementaryView.swift
│ ├── Cell.swift
│ ├── Layout
│ │ ├── VerticalLayout.swift
│ │ ├── HorizontalLayout.swift
│ │ ├── VerticalGridLayout.swift
│ │ ├── DefaultCompositionalLayoutSectionFactory.swift
│ │ └── CompositionalLayoutSectionFactory.swift
│ ├── List.swift
│ └── Section.swift
└── KarrotListKitMacros
│ ├── Supports
│ ├── KarrotListKitMacroError.swift
│ └── AccessLevelModifier.swift
│ ├── KarrotListKitPlugin.swift
│ └── AddComponentModifierMacro.swift
├── Tests
├── KarrotListKitTests
│ ├── Utils
│ │ ├── Collection+SafeIndexTests.swift
│ │ ├── Any+EquatableTests.swift
│ │ └── ChunkedTests.swift
│ ├── FeatureFlagProviderTests.swift
│ ├── AnyComponentTests.swift
│ ├── ComponentTests.swift
│ ├── RemoteImagePrefetchingPluginTest.swift
│ ├── TestDoubles
│ │ └── ComponentTestDouble.swift
│ ├── CollectionViewLayoutAdapterTests.swift
│ └── ResultBuildersTests.swift
└── KarrotListKitMacrosTests
│ └── AddComponentModifierMacroTests.swift
├── Package.resolved
├── Package.swift
├── README.md
└── LICENSE
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @OhKanghoon @jaxtynSong
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /.swiftpm
4 | /Packages
5 | xcuserdata/
6 | DerivedData/
7 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - platform: ios
5 | documentation_targets:
6 | - KarrotListKit
7 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Utils/Collection+SafeIndex.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | extension Collection {
8 |
9 | subscript(safe index: Index) -> Element? {
10 | indices.contains(index) ? self[index] : nil
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/KarrotListKitMacros/Supports/KarrotListKitMacroError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // KarrotListKitMacroError.swift
3 | // KarrotListKit
4 | //
5 | // Created by elon on 9/11/25.
6 | //
7 |
8 | import Foundation
9 |
10 | struct KarrotListKitMacroError: Error {
11 | let message: String
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/FeatureFlag/DefaultFeatureFlagProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | final class DefaultFeatureFlagProvider: FeatureFlagProviding {
8 |
9 | func featureFlags() -> [FeatureFlagItem] {
10 | []
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp/SampleApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
3 | //
4 |
5 | import SwiftUI
6 |
7 | @main
8 | struct SampleApp: App {
9 | var body: some Scene {
10 | WindowGroup {
11 | RootView()
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Component/IdentifiableComponent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | #if canImport(UIKit)
8 |
9 | /// Represents a component that can be uniquely identify.
10 | public protocol IdentifiableComponent: Identifiable, Component {}
11 | #endif
12 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/MacroInterface/AddComponentModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | #if canImport(UIKit)
8 | @attached(peer, names: arbitrary)
9 | public macro AddComponentModifier() = #externalMacro(
10 | module: "KarrotListKitMacros",
11 | type: "AddComponentModifierMacro"
12 | )
13 | #endif
14 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp.xcodeproj/xcshareddata/IDETemplateMacros.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | FILEHEADER
6 |
7 | // ___COPYRIGHT___
8 | //
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Sources/KarrotListKitMacros/KarrotListKitPlugin.swift:
--------------------------------------------------------------------------------
1 | //
2 | // KarrotListKitPlugin.swift
3 | // KarrotListKit
4 | //
5 | // Created by Daangn Jaxtyn on 7/18/25.
6 | //
7 |
8 | import SwiftCompilerPlugin
9 | import SwiftSyntaxMacros
10 |
11 | @main
12 | struct KarrotListKitKitPlugin: CompilerPlugin {
13 | let providingMacros: [Macro.Type] = [
14 | AddComponentModifierMacro.self,
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/draft-release-note.yml:
--------------------------------------------------------------------------------
1 | name: Draft Release Note
2 | on:
3 | push:
4 | tags:
5 | - "[0-9]+.[0-9]+.[0-9]+"
6 |
7 | jobs:
8 | draft-release-note:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - uses: release-drafter/release-drafter@v6
15 | with:
16 | version: ${{ github.ref_name }}
17 |
18 | env:
19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/ListingViewEventStorage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | final class ListingViewEventStorage {
8 |
9 | private var source: [AnyHashable: Any] = [:]
10 |
11 | func event(for type: E.Type) -> E? {
12 | source[String(reflecting: type)] as? E
13 | }
14 |
15 | func register(_ event: some ListingViewEvent) {
16 | source[event.id] = event
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/FeatureFlag/FeatureFlagType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// Define the feature flags
8 | public enum FeatureFlagType: Equatable {
9 |
10 | /// Improve scrolling performance using calculated view size.
11 | /// You can find more information at https://developer.apple.com/documentation/uikit/building-high-performance-lists-and-collection-views
12 | case usesCachedViewSize
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/ListingViewEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | protocol ListingViewEvent {
8 | associatedtype Input
9 | associatedtype Output
10 |
11 | var id: AnyHashable { get }
12 |
13 | var handler: (Input) -> Output { get }
14 | }
15 |
16 | // MARK: - Default Implementation
17 |
18 | extension ListingViewEvent {
19 | var id: AnyHashable {
20 | String(reflecting: Self.self)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Extension/UICollectionView+Init.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | extension UICollectionView {
9 | public convenience init(layoutAdapter: CollectionViewLayoutAdaptable) {
10 | self.init(
11 | frame: .zero,
12 | collectionViewLayout: UICollectionViewCompositionalLayout(
13 | sectionProvider: layoutAdapter.sectionLayout
14 | )
15 | )
16 | }
17 | }
18 | #endif
19 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Prefetching/PrefetchableComponent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// A protocol that needs resources to be prefetched.
8 | public protocol ComponentResourcePrefetchable {}
9 |
10 | /// A protocol that needs remote image to be prefetched.
11 | public protocol ComponentRemoteImagePrefetchable: ComponentResourcePrefetchable {
12 |
13 | /// The remote image URLs that need to be prefetched.
14 | var remoteImageURLs: [URL] { get }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/PullToRefreshEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the pull to refresh event information and contains a closure object for handling the pull to refresh event.
9 | public struct PullToRefreshEvent: ListingViewEvent {
10 | public struct EventContext {}
11 |
12 | /// A closure that's called when the user pull to refresh content.
13 | let handler: (EventContext) -> Void
14 | }
15 | #endif
16 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Utils/Any+Equatable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | extension Equatable {
8 |
9 | func isEqual(_ other: any Equatable) -> Bool {
10 | guard let other = other as? Self else {
11 | return other.isExactlyEqual(self)
12 | }
13 | return self == other
14 | }
15 |
16 | private func isExactlyEqual(_ other: any Equatable) -> Bool {
17 | guard let other = other as? Self else {
18 | return false
19 | }
20 | return self == other
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/FeatureFlag/KarrotListKitFeatureFlag.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// An interface for injecting a feature flag provider.
8 | public enum KarrotListKitFeatureFlag {
9 |
10 | /// The feature flag provider used by `KarrotListKit`.
11 | ///
12 | /// By default, this is set to `DefaultFeatureFlagProvider`.
13 | /// You can replace it with a custom provider to change the feature flag behavior.
14 | public static var provider: FeatureFlagProviding = DefaultFeatureFlagProvider()
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/Utils/Collection+SafeIndexTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import XCTest
6 |
7 | @testable import KarrotListKit
8 |
9 | final class Collection_SafeIndexTests: XCTestCase {
10 |
11 | func test_safeIndex_해당_인덱스에_값이_없으면_nil_을_반환합니다() {
12 | // given
13 | let array = [1, 2, 3]
14 |
15 | // then
16 | XCTAssertNil(array[safe: 3])
17 | }
18 |
19 | func test_safeIndex_해당_인덱스에_값이_있으면_반환합니다() {
20 | // given
21 | let array = [1, 2, 3]
22 |
23 | // then
24 | XCTAssertEqual(array[safe: 1], 2)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/FeatureFlag/FeatureFlagProviding.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// A protocol for providing feature flags.
8 | public protocol FeatureFlagProviding {
9 |
10 | /// Returns an array of feature flags.
11 | ///
12 | /// - Returns: An array of `FeatureFlagItem`.
13 | func featureFlags() -> [FeatureFlagItem]
14 | }
15 |
16 | extension FeatureFlagProviding {
17 |
18 | func isEnabled(for type: FeatureFlagType) -> Bool {
19 | featureFlags()
20 | .first(where: { $0.type == type })?
21 | .isEnabled ?? false
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: "🚀 $RESOLVED_VERSION"
2 | tag-template: "$RESOLVED_VERSION"
3 | categories:
4 | - title: "✨ Features"
5 | label: "Feature"
6 |
7 | - title: "😎 Update"
8 | label: "Update"
9 |
10 | - title: "🐛 Bug Fixes"
11 | label: "Bug"
12 |
13 | - title: "🤓 Improvements"
14 | label: "Improvement"
15 |
16 | - title: "📚 Documentation"
17 | label: "Docs"
18 |
19 | - title: "🧰 Maintenance"
20 | labels:
21 | - "CI"
22 |
23 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
24 | change-title-escapes: '\<*_&'
25 | template: |
26 | ## What’s Changed
27 |
28 | $CHANGES
29 |
30 | ## What's Included
31 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/DidScrollEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the did scroll event information and contains a closure object for handling the did scroll event.
9 | public struct DidScrollEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The collectionView object in which the scrolling occurred.
13 | public let collectionView: UICollectionView
14 | }
15 |
16 | /// A closure that's called when the user scrolls the content view within the collectionView.
17 | let handler: (EventContext) -> Void
18 | }
19 | #endif
20 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "differencekit",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/ra1028/DifferenceKit.git",
7 | "state" : {
8 | "revision" : "073b9671ce2b9b5b96398611427a1f929927e428",
9 | "version" : "1.3.0"
10 | }
11 | },
12 | {
13 | "identity" : "swift-syntax",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/swiftlang/swift-syntax.git",
16 | "state" : {
17 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
18 | "version" : "601.0.1"
19 | }
20 | }
21 | ],
22 | "version" : 2
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/ShouldScrollToTopEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the shouldScrollToTop event information and contains a closure object for shouldScrollToTop event.
9 | public struct ShouldScrollToTopEvent: ListingViewEvent {
10 | public struct EventContext {
11 | /// The collectionView object requesting this information.
12 | public let collectionView: UICollectionView
13 | }
14 |
15 | /// A closure that's called when if the scroll view should scroll to the top of the content.
16 | let handler: (EventContext) -> Bool
17 | }
18 | #endif
19 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/DidScrollToTopEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the did scroll to top event information and contains a closure object for handling the did scroll to top event.
9 | public struct DidScrollToTopEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The collectionView object that perform the scrolling operation.
13 | public let collectionView: UICollectionView
14 | }
15 |
16 | /// A closure that's called when the user scrolled to the top of the content.
17 | let handler: (EventContext) -> Void
18 | }
19 | #endif
20 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Prefetching/RemoteImagePrefetching.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// A protocol that prefetching remote images.
8 | public protocol RemoteImagePrefetching {
9 |
10 | /// Prefetches an image from a given URL.
11 | ///
12 | /// - Parameter url: The URL of the image to be prefetched.
13 | /// - Returns: A UUID representing the prefetch task. This can be used to cancel the task if needed.
14 | func prefetchImage(url: URL) -> UUID?
15 |
16 | /// Cancels a prefetch task with a given UUID.
17 | ///
18 | /// - Parameter uuid: The UUID of the prefetch task to be cancelled.
19 | func cancelTask(uuid: UUID)
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/WillBeginDraggingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the will begin dragging event information and contains a closure object for handling the will begin dragging event.
9 | public struct WillBeginDraggingEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The collectionView object that’s about to scroll the content view.
13 | public let collectionView: UICollectionView
14 | }
15 |
16 | /// A closure that's called when the collectionView is about to start scrolling the content.
17 | let handler: (EventContext) -> Void
18 | }
19 | #endif
20 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/ListingViewEventHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | protocol ListingViewEventHandler {
8 |
9 | var eventStorage: ListingViewEventStorage { get }
10 |
11 | func registerEvent(_ event: E) -> Self
12 |
13 | func event(for type: E.Type) -> E?
14 | }
15 |
16 | // MARK: - Default Implementation
17 |
18 | extension ListingViewEventHandler {
19 | func registerEvent(_ event: some ListingViewEvent) -> Self {
20 | eventStorage.register(event)
21 | return self
22 | }
23 |
24 | func event(for type: E.Type) -> E? {
25 | eventStorage.event(for: type)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/DidEndDeceleratingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the did end decelerating event information and contains a closure object for handling the did end decelerating event.
9 | public struct DidEndDeceleratingEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The collectionView object that’s decelerating the scrolling of the content view.
13 | public let collectionView: UICollectionView
14 | }
15 |
16 | /// A closure that's called when the collection view ended decelerating the scrolling movement.
17 | let handler: (EventContext) -> Void
18 | }
19 | #endif
20 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp/RootView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
3 | //
4 |
5 | import SwiftUI
6 |
7 | struct RootView: View {
8 | var body: some View {
9 | NavigationStack {
10 | List {
11 | NavigationLink("VerticalLayout") {
12 | UIViewRepresentation { _ in
13 | VerticalLayoutListView()
14 | }
15 | .ignoresSafeArea()
16 | .navigationTitle("VerticalLayout")
17 | .navigationBarTitleDisplayMode(.inline)
18 | }
19 | }
20 | .navigationTitle("KarrotListKit")
21 | .navigationBarTitleDisplayMode(.inline)
22 | }
23 | }
24 | }
25 |
26 | #Preview {
27 | RootView()
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/Cell/DidSelectEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | #if canImport(UIKit)
8 |
9 | /// This structure encapsulates the selection event information and contains a closure object for handling the selection event.
10 | public struct DidSelectEvent: ListingViewEvent {
11 |
12 | public struct EventContext {
13 |
14 | /// The index path of the cell that was selected.
15 | public let indexPath: IndexPath
16 |
17 | /// The component owned by the cell that was selected.
18 | public let anyComponent: AnyComponent
19 | }
20 |
21 | /// A closure that's called when the cell was selected
22 | let handler: (EventContext) -> Void
23 | }
24 | #endif
25 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/WillBeginDeceleratingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the will begin decelerating event information and contains a closure object for handling the will begin decelerating event.
9 | public struct WillBeginDeceleratingEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The collectionView object that’s decelerating the scrolling of the content view.
13 | public let collectionView: UICollectionView
14 | }
15 |
16 | /// A closure that's called when the collection view is starting to decelerate the scrolling movement.
17 | let handler: (EventContext) -> Void
18 | }
19 | #endif
20 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/FeatureFlag/FeatureFlagItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// Representing a feature flag item.
8 | public struct FeatureFlagItem {
9 |
10 | /// The type of the feature flag.
11 | public let type: FeatureFlagType
12 |
13 | /// A Boolean value indicating whether the feature flag is enabled.
14 | public let isEnabled: Bool
15 |
16 | /// Initializes a new `FeatureFlagItem`.
17 | ///
18 | /// - Parameters:
19 | /// - type: The type of the feature flag.
20 | /// - isEnabled: A Boolean value indicating whether the feature flag is enabled.
21 | public init(
22 | type: FeatureFlagType,
23 | isEnabled: Bool
24 | ) {
25 | self.type = type
26 | self.isEnabled = isEnabled
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Prefetching/Plugins/CollectionViewPrefetchingPlugin.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Combine
6 | import Foundation
7 |
8 | /// A protocol that asynchronously prefetching component resource on CollectionView
9 | public protocol CollectionViewPrefetchingPlugin {
10 |
11 | /// Performs the task of prefetching resources that the component needs.
12 | /// Returns a type of AnyCancellable? for the possibility of cancelling the prefetch operation.
13 | ///
14 | /// - Parameter component: The component that needs its resources to be prefetched.
15 | /// - Returns: An optional instance which can be used to cancel the prefetch operation if needed.
16 | func prefetch(with component: ComponentResourcePrefetchable) -> AnyCancellable?
17 | }
18 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp/Utilities/String+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
3 | //
4 |
5 | extension Character {
6 | static let lowercaseLetters: [Self] = (
7 | UnicodeScalar("a").value...UnicodeScalar("z").value
8 | ).compactMap { UnicodeScalar($0).flatMap(Character.init(_:)) }
9 | }
10 |
11 | extension String {
12 | static func randomWord(length: Int) -> Self {
13 | let characters = (0..) -> Self {
18 | let words = (0.. Void
24 | }
25 | #endif
26 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/Cell/HighlightEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the highlight event information and contains a closure object for handling the highlight event.
9 | public struct HighlightEvent: ListingViewEvent {
10 |
11 | public struct EventContext {
12 |
13 | /// The index path of the view that was highlighted.
14 | public let indexPath: IndexPath
15 |
16 | /// The component owned by the view that was highlighted.
17 | public let anyComponent: AnyComponent
18 |
19 | /// The content owned by the view that was highlighted.
20 | public let content: UIView?
21 | }
22 |
23 | /// A closure that's called when the cell was highlighted
24 | let handler: (EventContext) -> Void
25 | }
26 | #endif
27 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/Cell/UnhighlightEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the unhighlight event information and contains a closure object for handling the unhighlight event.
9 | public struct UnhighlightEvent: ListingViewEvent {
10 |
11 | public struct EventContext {
12 |
13 | /// The index path of the view that was unhighlight.
14 | public let indexPath: IndexPath
15 |
16 | /// The component owned by the view that was unhighlight.
17 | public let anyComponent: AnyComponent
18 |
19 | /// The content owned by the view that was unhighlight.
20 | public let content: UIView?
21 | }
22 |
23 | /// A closure that's called when the cell was unhighlight
24 | let handler: (EventContext) -> Void
25 | }
26 | #endif
27 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/Common/DidEndDisplayingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the didEndDisplaying event information and contains a closure object for handling the didEndDisplaying event.
9 | public struct DidEndDisplayingEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The index path of the view that was removed.
13 | public let indexPath: IndexPath
14 |
15 | /// The component owned by the view that was removed.
16 | public let anyComponent: AnyComponent
17 |
18 | /// The content owned by the view that was removed.
19 | public let content: UIView?
20 | }
21 |
22 | /// A closure that's called when the view was removed.
23 | let handler: (EventContext) -> Void
24 | }
25 | #endif
26 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Extension/UIView+TraitCollection.swift .swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | extension UIView {
9 |
10 | func shouldInvalidateContentSize(
11 | previousTraitCollection: UITraitCollection?
12 | ) -> Bool {
13 | if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
14 | return true
15 | }
16 |
17 | if traitCollection.legibilityWeight != previousTraitCollection?.legibilityWeight {
18 | return true
19 | }
20 |
21 | if traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass ||
22 | traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass {
23 | return true
24 | }
25 |
26 | return false
27 | }
28 | }
29 | #endif
30 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/DidEndDraggingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the did end dragging event information and contains a closure object for handling the did end dragging event.
9 | public struct DidEndDraggingEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The collectionView object where the user ended the touch.
13 | public let collectionView: UICollectionView
14 |
15 | /// true if the scrolling movement will continue, but decelerate, after a touch-up gesture during a dragging operation.\
16 | /// If the value is false, scrolling stops immediately upon touch-up.
17 | public let decelerate: Bool
18 | }
19 |
20 | /// A closure that's called when the user finished scrolling the content.
21 | let handler: (EventContext) -> Void
22 | }
23 | #endif
24 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/Utils/Any+EquatableTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import XCTest
6 |
7 | @testable import KarrotListKit
8 |
9 | final class Any_EquatableTests: XCTestCase {
10 |
11 | func test_isEqual_any_opaque_type_의_값이_같으면_true() {
12 | // given
13 | let object1: any SomeProtocol = SomeObject(id: 1)
14 | let object2: any SomeProtocol = SomeObject(id: 1)
15 |
16 | // then
17 | XCTAssertTrue(object1.isEqual(object2))
18 | }
19 |
20 | func test_isEqual_any_opaque_type_의_값이_다르면_false() {
21 | // given
22 | let object1: any SomeProtocol = SomeObject(id: 1)
23 | let object2: any SomeProtocol = SomeObject(id: 2)
24 |
25 | // then
26 | XCTAssertFalse(object1.isEqual(object2))
27 | }
28 | }
29 |
30 |
31 | // MARK: - Test Object
32 |
33 | private protocol SomeProtocol: Equatable {
34 | var id: Int { get }
35 | }
36 |
37 | private struct SomeObject: SomeProtocol {
38 | let id: Int
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/WillEndDraggingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This structure encapsulates the will end dragging event information and contains a closure object for handling the will end dragging event.
9 | public struct WillEndDraggingEvent: ListingViewEvent {
10 | public struct EventContext {
11 |
12 | /// The collectionView object where the user ended the touch.
13 | public let collectionView: UICollectionView
14 |
15 | /// The velocity of the collectionView (in points per millisecond) at the moment the touch was released.
16 | public let velocity: CGPoint
17 |
18 | /// The expected offset when the scrolling action decelerates to a stop.
19 | public let targetContentOffset: UnsafeMutablePointer
20 | }
21 |
22 | /// A closure that's called when the user finishes scrolling the content.
23 | let handler: (EventContext) -> Void
24 | }
25 | #endif
26 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Event/List/ReachedEndEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// An event that triggers when the user scrolls to the end of a list view.
8 | public struct ReachedEndEvent: ListingViewEvent {
9 |
10 | /// Context for the `ReachedEndEvent`.
11 | public struct EventContext {}
12 |
13 | /// Defines the offset from the end of the list view that will trigger the event.
14 | public enum OffsetFromEnd {
15 | /// Triggers the event when the user scrolls within a multiple of the height of the content view.
16 | case relativeToContainerSize(multiplier: CGFloat)
17 | /// Triggers the event when the user scrolls within an absolute point value from the end.
18 | case absolute(CGFloat)
19 | }
20 |
21 | /// The offset from the end of the list view that will trigger the event.
22 | let offset: OffsetFromEnd
23 | /// The handler that will be called when the event is triggered.
24 | let handler: (EventContext) -> Void
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Adapter/CollectionViewAdapterUpdateStrategy.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | /// The approaches for updating the content of a `UICollectionView`.
8 | public enum CollectionViewAdapterUpdateStrategy {
9 |
10 | /// Performs animated batch updates by calling `performBatchUpdates(…)` with the new content.
11 | case animatedBatchUpdates
12 |
13 | /// Performs non-animated batch updates by wrapping a call to `performBatchUpdates(…)` with the
14 | /// new content within a `UIView.performWithoutAnimation(…)` closure.
15 | ///
16 | /// More performant than `reloadData`, as it does not recreate and reconfigure all visible
17 | /// cells.
18 | case nonanimatedBatchUpdates
19 |
20 | /// Performs non-animated updates by calling `reloadData()`, which recreates and reconfigures
21 | /// all visible cells.
22 | ///
23 | /// UIKit engineers have suggested that we should never need to call `reloadData` on updates,
24 | /// and instead just use batch updates for all content updates.
25 | case reloadData
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/SwiftUISupport/ComponentRepresented.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(SwiftUI) && canImport(UIKit)
6 | import SwiftUI
7 |
8 | struct ComponentRepresented: UIViewRepresentable {
9 | private let component: C
10 |
11 | init(component: C) {
12 | self.component = component
13 | }
14 |
15 | func makeUIView(context: Context) -> C.Content {
16 | component.renderContent(coordinator: context.coordinator)
17 | }
18 |
19 | func updateUIView(_ uiView: C.Content, context: Context) {
20 | component.render(in: uiView, coordinator: context.coordinator)
21 | }
22 |
23 | func makeCoordinator() -> C.Coordinator {
24 | component.makeCoordinator()
25 | }
26 | }
27 |
28 | extension Component {
29 | /// This helper method allows the Component to be used in SwiftUI.
30 | ///
31 | /// Component has an API similar to UIViewRepresentable.
32 | /// By using this method, you can easily migrate to SwiftUI.
33 | public func toSwiftUI() -> some View {
34 | ComponentRepresented(component: self)
35 | }
36 | }
37 | #endif
38 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp/Samples/VerticalLayout/VerticalLayoutItemComponent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
3 | //
4 |
5 | import KarrotListKit
6 |
7 | struct VerticalLayoutItemComponent: Component {
8 |
9 | typealias ViewModel = VerticalLayoutItemView.ViewModel
10 |
11 | let viewModel: ViewModel
12 |
13 | @AddComponentModifier
14 | var onTapButtonHandler: (() -> Void)?
15 |
16 | @AddComponentModifier
17 | var onTapButtonWithValueHandler: ((Int) -> Void)?
18 |
19 | @AddComponentModifier
20 | var onTapButtonWithValuesHandler: ((Int, String) -> Void)?
21 |
22 | @AddComponentModifier
23 | var onTapButtonWithNamedValuesHandler: ((_ intValue: Int, _ stringValue: String) -> Void)?
24 |
25 | init(viewModel: ViewModel) {
26 | self.viewModel = viewModel
27 | }
28 |
29 | func renderContent(coordinator: ()) -> VerticalLayoutItemView {
30 | VerticalLayoutItemView(
31 | viewModel: viewModel
32 | )
33 | }
34 |
35 | func render(in content: VerticalLayoutItemView, coordinator: ()) {
36 | content.viewModel = viewModel
37 | }
38 |
39 | var layoutMode: ContentLayoutMode {
40 | .flexibleHeight(estimatedHeight: 54.0)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/FeatureFlagProviderTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2025 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 | import XCTest
7 |
8 | @testable import KarrotListKit
9 |
10 | final class FeatureFlagProviderTests: XCTestCase {
11 |
12 | final class FeatureFlagProviderStub: FeatureFlagProviding {
13 |
14 | var featureFlagsStub: [FeatureFlagItem] = []
15 |
16 | func featureFlags() -> [FeatureFlagItem] {
17 | featureFlagsStub
18 | }
19 | }
20 |
21 | func test_default_featureFlags_is_empty() {
22 | // given
23 | let sut = KarrotListKitFeatureFlag.provider
24 |
25 | // when
26 | let featureFlags = sut.featureFlags()
27 |
28 | // then
29 | XCTAssertTrue(featureFlags.isEmpty)
30 | }
31 |
32 | func test_usesCachedViewSize_isEnabled() {
33 | [true, false].forEach { flag in
34 | // given
35 | let provider = FeatureFlagProviderStub()
36 | provider.featureFlagsStub = [.init(type: .usesCachedViewSize, isEnabled: flag)]
37 | KarrotListKitFeatureFlag.provider = provider
38 |
39 | // when
40 | let isEnabled = KarrotListKitFeatureFlag.provider.isEnabled(for: .usesCachedViewSize)
41 |
42 | // then
43 | XCTAssertEqual(isEnabled, flag)
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Builder/SectionsBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | #if canImport(UIKit)
8 |
9 | /// Provides a resultBuilder for creating an array of section.
10 | @resultBuilder
11 | public enum SectionsBuilder {
12 | public static func buildBlock(_ components: Section...) -> [Section] {
13 | components
14 | }
15 |
16 | public static func buildBlock(_ components: [Section]...) -> [Section] {
17 | components.flatMap { $0 }
18 | }
19 |
20 | public static func buildBlock(_ components: [Section]) -> [Section] {
21 | components
22 | }
23 |
24 | public static func buildOptional(_ component: [Section]?) -> [Section] {
25 | component ?? []
26 | }
27 |
28 | public static func buildEither(first component: [Section]) -> [Section] {
29 | component
30 | }
31 |
32 | public static func buildEither(second component: [Section]) -> [Section] {
33 | component
34 | }
35 |
36 | public static func buildExpression(_ expression: Section...) -> [Section] {
37 | expression
38 | }
39 |
40 | public static func buildExpression(_ expression: [Section]...) -> [Section] {
41 | expression.flatMap { $0 }
42 | }
43 |
44 | public static func buildArray(_ components: [[Section]]) -> [Section] {
45 | components.flatMap { $0 }
46 | }
47 | }
48 | #endif
49 |
--------------------------------------------------------------------------------
/.github/workflows/ci-sampleapp.yml:
--------------------------------------------------------------------------------
1 | name: CI (SampleApp)
2 |
3 | on:
4 | pull_request:
5 | paths:
6 | - "Package.swift"
7 | - "Package.resolved"
8 | - "Sources/**"
9 | - "Tests/**"
10 | - "Examples/KarrotListKitSampleApp/**"
11 | workflow_dispatch:
12 |
13 | concurrency:
14 | group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}-${{ github.job }}"
15 | cancel-in-progress: true
16 |
17 | jobs:
18 | build:
19 | runs-on: macos-15
20 | defaults:
21 | run:
22 | working-directory: Examples/KarrotListKitSampleApp
23 |
24 | env:
25 | SCHEME: KarrotListKitSampleApp
26 | DESTINATION: platform=iOS Simulator,name=iPhone 16,OS=18.5
27 |
28 | steps:
29 | - uses: actions/checkout@v4
30 |
31 | - name: Setup Xcode Version
32 | run: sudo xcode-select --switch /Applications/Xcode_16.4.app
33 | shell: bash
34 |
35 | - name: Set Swift Syntax Prebuilts
36 | run: |
37 | defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts YES
38 |
39 | - name: Run `build-for-testing`
40 | run: |
41 | set -o pipefail && xcodebuild build-for-testing \
42 | -scheme "$SCHEME" \
43 | -destination "$DESTINATION" \
44 | -configuration Debug \
45 | | xcbeautify --renderer github-actions
46 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/View/ComponentRenderable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | protocol ComponentRenderable: AnyObject {
9 | var componentContainerView: UIView { get }
10 |
11 | var renderedContent: UIView? { get set }
12 |
13 | var coordinator: Any? { get set }
14 |
15 | var renderedComponent: AnyComponent? { get set }
16 |
17 | func render(component: AnyComponent)
18 | }
19 |
20 | // MARK: - Det
21 |
22 | extension ComponentRenderable where Self: UICollectionViewCell {
23 | var componentContainerView: UIView {
24 | contentView
25 | }
26 | }
27 |
28 | extension ComponentRenderable where Self: UICollectionReusableView {
29 | var componentContainerView: UIView {
30 | self
31 | }
32 | }
33 |
34 | extension ComponentRenderable where Self: UIView {
35 |
36 | func render(component: AnyComponent) {
37 | if let renderedContent {
38 | component.render(in: renderedContent, coordinator: coordinator ?? ())
39 | renderedComponent = component
40 | } else {
41 | coordinator = component.makeCoordinator()
42 | let content = component.renderContent(coordinator: coordinator ?? ())
43 | component.layout(content: content, in: componentContainerView)
44 | renderedContent = content
45 | render(component: component)
46 | }
47 | }
48 | }
49 | #endif
50 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import CompilerPluginSupport
5 | import PackageDescription
6 |
7 | let package = Package(
8 | name: "KarrotListKit",
9 | platforms: [.iOS(.v13), .macOS(.v10_15)],
10 | products: [
11 | .library(
12 | name: "KarrotListKit",
13 | targets: ["KarrotListKit"]
14 | ),
15 | ],
16 | dependencies: [
17 | .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.0.0"..<"603.0.0"),
18 | .package(
19 | url: "https://github.com/ra1028/DifferenceKit.git",
20 | .upToNextMajor(from: "1.0.0")
21 | ),
22 | ],
23 | targets: [
24 | .target(
25 | name: "KarrotListKit",
26 | dependencies: [
27 | "DifferenceKit",
28 | "KarrotListKitMacros",
29 | ]
30 | ),
31 | .macro(
32 | name: "KarrotListKitMacros",
33 | dependencies: [
34 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
35 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
36 | ]
37 | ),
38 | .testTarget(
39 | name: "KarrotListKitTests",
40 | dependencies: ["KarrotListKit"]
41 | ),
42 | .testTarget(
43 | name: "KarrotListKitMacrosTests",
44 | dependencies: [
45 | "KarrotListKitMacros",
46 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
47 | ]
48 | ),
49 | ]
50 | )
51 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Builder/CellsBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | #if canImport(UIKit)
8 |
9 | /// Provides a resultBuilder for creating an array of cell.
10 | @resultBuilder
11 | public enum CellsBuilder {
12 | public static func buildBlock(_ components: Cell...) -> [Cell] {
13 | components
14 | }
15 |
16 | public static func buildBlock(_ components: [Cell]...) -> [Cell] {
17 | components.flatMap { $0 }
18 | }
19 |
20 | public static func buildBlock(_ components: [Cell]) -> [Cell] {
21 | components
22 | }
23 |
24 | public static func buildOptional(_ component: [Cell]?) -> [Cell] {
25 | component ?? []
26 | }
27 |
28 | public static func buildEither(first component: [Cell]) -> [Cell] {
29 | component
30 | }
31 |
32 | public static func buildEither(first component: Cell...) -> [Cell] {
33 | component
34 | }
35 |
36 | public static func buildEither(first component: () -> [Cell]) -> [Cell] {
37 | component()
38 | }
39 |
40 | public static func buildEither(second component: [Cell]) -> [Cell] {
41 | component
42 | }
43 |
44 | public static func buildExpression(_ expression: Cell...) -> [Cell] {
45 | expression
46 | }
47 |
48 | public static func buildExpression(_ expression: [Cell]...) -> [Cell] {
49 | expression.flatMap { $0 }
50 | }
51 |
52 | public static func buildArray(_ components: [[Cell]]) -> [Cell] {
53 | components.flatMap { $0 }
54 | }
55 | }
56 | #endif
57 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Component/ContentLayoutMode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | #if canImport(UIKit)
8 | import CoreGraphics
9 |
10 | /// An enumeration that defines how a `Component`'s content should be laid out in a `UICollectionView`.
11 | public enum ContentLayoutMode: Equatable {
12 |
13 | /// The content's width and height are adjusted to fit the size of the parent container.
14 | case fitContainer
15 |
16 | /// The content's width is determined by the parent container, and its height is adjusted to fit the size of the content itself.
17 | /// An estimated height is provided for use before the actual height is calculated.
18 | ///
19 | /// - Parameter estimatedHeight: The estimated height of the content.
20 | case flexibleHeight(estimatedHeight: CGFloat)
21 |
22 | /// The content's height is determined by the parent container, and its width is adjusted to fit the size of the content itself.
23 | /// An estimated width is provided for use before the actual width is calculated.
24 | ///
25 | /// - Parameter estimatedWidth: The estimated width of the content.
26 | case flexibleWidth(estimatedWidth: CGFloat)
27 |
28 | /// Both the content's width and height are adjusted to fit the size of the content itself.
29 | /// An estimated size is provided for use before the actual size is calculated.
30 | ///
31 | /// - Parameter estimatedSize: The estimated size of the content.
32 | case fitContent(estimatedSize: CGSize)
33 | }
34 | #endif
35 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/AnyComponentTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import XCTest
6 |
7 | @testable import KarrotListKit
8 |
9 | #if canImport(UIKit)
10 | final class AnyComponentTests: XCTestCase {
11 |
12 | func test_properties() {
13 | // given
14 | let component = DummyComponent()
15 | let anyComponent = AnyComponent(component)
16 |
17 | // when & then
18 | XCTAssertEqual(component.layoutMode, anyComponent.layoutMode)
19 | XCTAssertEqual(component.reuseIdentifier, anyComponent.reuseIdentifier)
20 | }
21 |
22 | func test_renderContent() {
23 | // given
24 | let content = UIView()
25 | var component = ComponentStub()
26 | component.contentStub = content
27 | let anyComponent = AnyComponent(component)
28 |
29 | // when
30 | let renderContent = anyComponent.renderContent(coordinator: ())
31 |
32 | // then
33 | XCTAssertIdentical(renderContent, content)
34 | }
35 |
36 | func test_render() {
37 | // given
38 | let component = ComponentSpy()
39 | let anyComponent = AnyComponent(component)
40 |
41 | // when
42 | anyComponent.render(in: .init(), coordinator: ())
43 |
44 | // then
45 | XCTAssertEqual(component.renderCallCount, 1)
46 | }
47 |
48 | func test_type() {
49 | // given
50 | let component = ComponentSpy()
51 | let anyComponent = AnyComponent(component)
52 |
53 | // when & then
54 | XCTAssertIdentical(anyComponent.as(ComponentSpy.self), component)
55 | }
56 | }
57 | #endif
58 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Prefetching/Plugins/RemoteImagePrefetchingPlugin.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Combine
6 | import Foundation
7 |
8 | /// This class is a concrete implementation of the CollectionViewPrefetchingPlugin protocol.
9 | /// It uses an instance of a type conforming to the RemoteImagePrefetching protocol to prefetch remote images.
10 | public final class RemoteImagePrefetchingPlugin: CollectionViewPrefetchingPlugin {
11 |
12 | private let remoteImagePrefetcher: RemoteImagePrefetching
13 |
14 | /// Initializes a new instance of RemoteImagePrefetchingPlugin.
15 | ///
16 | /// - Parameter remoteImagePrefetcher: An instance of a type conforming to the RemoteImagePrefetching protocol to prefetch remote images.
17 | public init(remoteImagePrefetcher: RemoteImagePrefetching) {
18 | self.remoteImagePrefetcher = remoteImagePrefetcher
19 | }
20 |
21 | /// Prefetches resources for a given component.
22 | ///
23 | /// - Parameter component: The component that needs its resources to be prefetched.
24 | /// - Returns: An optional AnyCancellable instance which can be used to cancel the prefetch operation if needed.
25 | public func prefetch(with component: ComponentResourcePrefetchable) -> AnyCancellable? {
26 | guard let component = component as? ComponentRemoteImagePrefetchable else {
27 | return nil
28 | }
29 |
30 | let uuids = component.remoteImageURLs.compactMap {
31 | remoteImagePrefetcher.prefetchImage(url: $0)
32 | }
33 |
34 | return AnyCancellable { [weak self] in
35 | for uuid in uuids {
36 | self?.remoteImagePrefetcher.cancelTask(uuid: uuid)
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp/Utilities/UIViewRepresentation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
3 | //
4 |
5 | import SwiftUI
6 |
7 | struct UIViewRepresentation<
8 | UIViewType: UIView,
9 | Coordinator
10 | >: UIViewRepresentable {
11 |
12 | private let _makeCoordinator: () -> Coordinator
13 | private let _makeUIView: (_ context: Context) -> UIViewType
14 | private let _updateUIView: (_ uiView: UIViewType, _ context: Context) -> Void
15 | private let _sizeThatFits: (
16 | _ proposal: ProposedViewSize,
17 | _ uiView: UIViewType,
18 | _ context: Context
19 | ) -> CGSize?
20 |
21 | init(
22 | makeCoordinator: @escaping () -> Coordinator = { () },
23 | makeUIView: @escaping (_ context: Context) -> UIViewType,
24 | updateUIView: @escaping (_ uiView: UIViewType, _ context: Context) -> Void = { _, _ in },
25 | sizeThatFits: @escaping (
26 | _ proposal: ProposedViewSize,
27 | _ uiView: UIViewType,
28 | _ context: Context
29 | ) -> CGSize? = { _, _, _ in nil }
30 | ) {
31 | self._makeCoordinator = makeCoordinator
32 | self._makeUIView = makeUIView
33 | self._updateUIView = updateUIView
34 | self._sizeThatFits = sizeThatFits
35 | }
36 |
37 | func makeCoordinator() -> Coordinator {
38 | _makeCoordinator()
39 | }
40 |
41 | func makeUIView(context: Context) -> UIViewType {
42 | _makeUIView(context)
43 | }
44 |
45 | func updateUIView(_ uiView: UIViewType, context: Context) {
46 | _updateUIView(uiView, context)
47 | }
48 |
49 | func sizeThatFits(
50 | _ proposal: ProposedViewSize,
51 | uiView: UIViewType,
52 | context: Context
53 | ) -> CGSize? {
54 | _sizeThatFits(proposal, uiView, context)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "0b5eaa67f50c0214fd087c1770246a75503c7051b3953d97f40cadf05c5f3f31",
3 | "pins" : [
4 | {
5 | "identity" : "differencekit",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/ra1028/DifferenceKit.git",
8 | "state" : {
9 | "revision" : "073b9671ce2b9b5b96398611427a1f929927e428",
10 | "version" : "1.3.0"
11 | }
12 | },
13 | {
14 | "identity" : "flexlayout",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/layoutBox/FlexLayout.git",
17 | "state" : {
18 | "revision" : "68c784bea59e4ed905f16cc6c62d22afd34d68d9",
19 | "version" : "2.2.0"
20 | }
21 | },
22 | {
23 | "identity" : "pinlayout",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/layoutBox/PinLayout.git",
26 | "state" : {
27 | "revision" : "8eaa3a15e4dcabaca4c95aea4c0ff0c9b4a67213",
28 | "version" : "1.10.5"
29 | }
30 | },
31 | {
32 | "identity" : "swift-syntax",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/swiftlang/swift-syntax.git",
35 | "state" : {
36 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
37 | "version" : "601.0.1"
38 | }
39 | },
40 | {
41 | "identity" : "yoga",
42 | "kind" : "remoteSourceControl",
43 | "location" : "https://github.com/facebook/yoga.git",
44 | "state" : {
45 | "revision" : "042f5013152eb81c1552dec945b88f7b95ca350f",
46 | "version" : "3.2.1"
47 | }
48 | }
49 | ],
50 | "version" : 3
51 | }
52 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/ComponentTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import XCTest
6 |
7 | @testable import KarrotListKit
8 |
9 | #if canImport(UIKit)
10 | import UIKit
11 |
12 | final class ComponentTests: XCTestCase {
13 |
14 | struct FatalComponent: Component {
15 | struct ViewModel: Equatable {}
16 | typealias Content = UIView
17 | typealias Coordinator = Void
18 | var layoutMode: ContentLayoutMode { fatalError() }
19 | var viewModel: ViewModel = .init()
20 | func renderContent(coordinator: Coordinator) -> UIView { fatalError() }
21 | func render(in content: UIView, coordinator: Coordinator) { fatalError() }
22 | }
23 |
24 | func test_given_same_type_when_compare_reuseIdentifier_then_equal() {
25 | // given
26 | let component1 = DummyComponent()
27 | let component2 = DummyComponent()
28 |
29 | // when & then
30 | XCTAssertEqual(component1.reuseIdentifier, component2.reuseIdentifier)
31 | }
32 |
33 | func test_given_other_type_when_compare_reuseIdentifier_then_not_equal() {
34 | // given
35 | let component1 = DummyComponent()
36 | let component2 = FatalComponent()
37 |
38 | // when & then
39 | XCTAssertNotEqual(component1.reuseIdentifier, component2.reuseIdentifier)
40 | }
41 |
42 | func test_when_layout_then_fit_container() {
43 | // given
44 | let component = DummyComponent()
45 | let content = component.renderContent(coordinator: ())
46 | let frame = CGRect(x: 0, y: 0, width: 200.0, height: 200.0)
47 | let container = UIView(frame: frame)
48 |
49 | // when
50 | component.layout(content: content, in: container)
51 | container.layoutIfNeeded()
52 |
53 | // then
54 | XCTAssertEqual(content.frame, frame)
55 | }
56 | }
57 | #endif
58 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/RemoteImagePrefetchingPluginTest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import XCTest
6 |
7 | @testable import KarrotListKit
8 |
9 | final class RemoteImagePrefetchingPluginTest: XCTestCase {
10 |
11 | final class RemoteImagePrefetchingSpy: RemoteImagePrefetching {
12 |
13 | var prefetchImageCallCount = 0
14 | func prefetchImage(url: URL) -> UUID? {
15 | prefetchImageCallCount += 1
16 | return UUID()
17 | }
18 |
19 | var cancelTaskCallCount = 0
20 | func cancelTask(uuid: UUID) {
21 | cancelTaskCallCount += 1
22 | }
23 | }
24 |
25 | struct ComponentRemoteImagePrefetchableDummy: ComponentRemoteImagePrefetchable {
26 |
27 | var remoteImageURLs: [URL] {
28 | [
29 | URL(string: "https://github.com/daangn/KarrotListKit")!,
30 | ]
31 | }
32 | }
33 |
34 | func test_given_imagePrefetchable_when_prefetch_then_fetch_image() {
35 | // given
36 | let imagePrefetcher = RemoteImagePrefetchingSpy()
37 | let sut = RemoteImagePrefetchingPlugin(
38 | remoteImagePrefetcher: imagePrefetcher
39 | )
40 |
41 | // when
42 | _ = sut.prefetch(with: ComponentRemoteImagePrefetchableDummy())
43 |
44 | // then
45 | XCTAssertEqual(imagePrefetcher.prefetchImageCallCount, 1)
46 | }
47 |
48 | func test_given_taskCancellable_when_cancel_prefetch_then_cancel_task() {
49 | // given
50 | let imagePrefetcher = RemoteImagePrefetchingSpy()
51 | let sut = RemoteImagePrefetchingPlugin(
52 | remoteImagePrefetcher: imagePrefetcher
53 | )
54 | let cancellable = sut.prefetch(with: ComponentRemoteImagePrefetchableDummy())
55 |
56 | // when
57 | cancellable?.cancel()
58 |
59 | // then
60 | XCTAssertEqual(imagePrefetcher.cancelTaskCallCount, 1)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/TestDoubles/ComponentTestDouble.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | @testable import KarrotListKit
9 |
10 | struct DummyComponent: Component, ComponentResourcePrefetchable {
11 |
12 | struct ViewModel: Equatable {}
13 |
14 | typealias Content = UIView
15 | typealias Coordinator = Void
16 |
17 | var layoutMode: ContentLayoutMode {
18 | .flexibleHeight(estimatedHeight: 44.0)
19 | }
20 |
21 | var viewModel: ViewModel = .init()
22 |
23 | func renderContent(coordinator: Coordinator) -> UIView {
24 | UIView()
25 | }
26 |
27 | func render(in content: UIView, coordinator: Coordinator) {
28 | // nothing
29 | }
30 | }
31 |
32 | struct ComponentStub: Component {
33 |
34 | struct ViewModel: Equatable {}
35 |
36 | typealias Content = UIView
37 | typealias Coordinator = Void
38 |
39 | var viewModel: ViewModel {
40 | viewModelStub
41 | }
42 |
43 | var layoutMode: ContentLayoutMode {
44 | layoutModeStub
45 | }
46 |
47 | var layoutModeStub: ContentLayoutMode!
48 | var viewModelStub: ViewModel!
49 | var contentStub: UIView!
50 |
51 | func renderContent(coordinator: Coordinator) -> UIView {
52 | contentStub
53 | }
54 |
55 | func render(in content: UIView, coordinator: Coordinator) {
56 | // nothing
57 | }
58 | }
59 |
60 | class ComponentSpy: Component {
61 |
62 | struct ViewModel: Equatable {}
63 |
64 | typealias Content = UIView
65 | typealias Coordinator = Void
66 |
67 | var viewModel: ViewModel {
68 | .init()
69 | }
70 |
71 | var layoutMode: ContentLayoutMode {
72 | .fitContainer
73 | }
74 |
75 | var renderContentCallCount = 0
76 | func renderContent(coordinator: Coordinator) -> UIView {
77 | renderContentCallCount += 1
78 | return .init()
79 | }
80 |
81 | var renderCallCount = 0
82 | func render(in content: UIView, coordinator: Coordinator) {
83 | renderCallCount += 1
84 | }
85 | }
86 | #endif
87 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | paths:
6 | - "Package.swift"
7 | - "Package.resolved"
8 | - "Sources/**"
9 | - "Tests/**"
10 | workflow_dispatch:
11 |
12 | concurrency:
13 | group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}-${{ github.job }}"
14 | cancel-in-progress: true
15 |
16 | jobs:
17 | build_and_test:
18 | name: Build and Test
19 | runs-on: macos-15
20 | strategy:
21 | matrix:
22 | xcode: ['16.4']
23 | config: ['Debug', 'Release']
24 |
25 | env:
26 | SCHEME: KarrotListKit
27 | DESTINATION: platform=iOS Simulator,name=iPhone 16,OS=18.5
28 |
29 | steps:
30 | - uses: actions/checkout@v4
31 |
32 | - name: Setup Xcode ${{ matrix.xcode }}
33 | run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app
34 | shell: bash
35 |
36 | - name: Set Swift Syntax Prebuilts
37 | run: |
38 | defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts YES
39 |
40 | - name: Run `build-for-testing`
41 | run: |
42 | set -o pipefail && xcodebuild build-for-testing \
43 | -scheme "$SCHEME" \
44 | -destination "$DESTINATION" \
45 | -configuration ${{ matrix.config }} \
46 | ENABLE_TESTABILITY=YES \
47 | | xcbeautify --renderer github-actions
48 |
49 | - name: Run `test-without-building`
50 | run: |
51 | set -o pipefail && xcodebuild test-without-building \
52 | -scheme "$SCHEME" \
53 | -destination "$DESTINATION" \
54 | -configuration ${{ matrix.config }} \
55 | ENABLE_TESTABILITY=YES \
56 | | xcbeautify --renderer github-actions
57 |
58 | check-macro-compatibility:
59 | name: Check Macro Compatibility
60 | runs-on: macos-latest
61 | steps:
62 | - name: Checkout repository
63 | uses: actions/checkout@v4
64 |
65 | - name: Run Swift Macro Compatibility Check
66 | uses: Matejkob/swift-macro-compatibility-check@v1
--------------------------------------------------------------------------------
/Sources/KarrotListKit/View/UICollectionComponentReusableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | final class UICollectionComponentReusableView: UICollectionReusableView, ComponentRenderable {
9 |
10 | var renderedContent: UIView?
11 |
12 | var coordinator: Any?
13 |
14 | var renderedComponent: AnyComponent?
15 |
16 | var onSizeChanged: ((CGSize) -> Void)?
17 |
18 | private var previousBounds: CGSize = .zero
19 |
20 | // MARK: - Initializing
21 |
22 | @available(*, unavailable)
23 | required init?(coder aDecoder: NSCoder) {
24 | fatalError("init(coder:) has not been implemented")
25 | }
26 |
27 | override init(frame: CGRect) {
28 | super.init(frame: frame)
29 |
30 | backgroundColor = .clear
31 | }
32 |
33 | // MARK: - Override Methods
34 |
35 | public override func traitCollectionDidChange(
36 | _ previousTraitCollection: UITraitCollection?
37 | ) {
38 | super.traitCollectionDidChange(previousTraitCollection)
39 |
40 | if shouldInvalidateContentSize(
41 | previousTraitCollection: previousTraitCollection
42 | ) {
43 | previousBounds = .zero
44 | }
45 | }
46 |
47 | public override func prepareForReuse() {
48 | super.prepareForReuse()
49 |
50 | previousBounds = .zero
51 | }
52 |
53 | public override func layoutSubviews() {
54 | super.layoutSubviews()
55 |
56 | previousBounds = bounds.size
57 | }
58 |
59 | override func preferredLayoutAttributesFitting(
60 | _ layoutAttributes: UICollectionViewLayoutAttributes
61 | ) -> UICollectionViewLayoutAttributes {
62 | let attributes = super.preferredLayoutAttributesFitting(layoutAttributes)
63 |
64 | guard let renderedContent else {
65 | return attributes
66 | }
67 |
68 | if KarrotListKitFeatureFlag.provider.isEnabled(for: .usesCachedViewSize),
69 | previousBounds == attributes.size {
70 | return attributes
71 | }
72 |
73 | let size = renderedContent.sizeThatFits(bounds.size)
74 |
75 | if renderedComponent != nil {
76 | onSizeChanged?(size)
77 | }
78 |
79 | attributes.frame.size = size
80 |
81 | return attributes
82 | }
83 | }
84 | #endif
85 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/View/UICollectionViewComponentCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Combine
6 |
7 | #if canImport(UIKit)
8 | import UIKit
9 |
10 | public final class UICollectionViewComponentCell: UICollectionViewCell, ComponentRenderable {
11 |
12 | public internal(set) var renderedContent: UIView?
13 |
14 | var coordinator: Any?
15 |
16 | var renderedComponent: AnyComponent?
17 |
18 | var cancellables: [AnyCancellable]?
19 |
20 | var onSizeChanged: ((CGSize) -> Void)?
21 |
22 | private var previousBounds: CGSize = .zero
23 |
24 | // MARK: - Initializing
25 |
26 | @available(*, unavailable)
27 | required init?(coder aDecoder: NSCoder) {
28 | fatalError("init(coder:) has not been implemented")
29 | }
30 |
31 | override init(frame: CGRect) {
32 | super.init(frame: frame)
33 |
34 | backgroundColor = .clear
35 | contentView.backgroundColor = .clear
36 | }
37 |
38 | deinit {
39 | cancellables?.forEach { $0.cancel() }
40 | }
41 |
42 | // MARK: - Override Methods
43 |
44 | public override func traitCollectionDidChange(
45 | _ previousTraitCollection: UITraitCollection?
46 | ) {
47 | super.traitCollectionDidChange(previousTraitCollection)
48 |
49 | if shouldInvalidateContentSize(
50 | previousTraitCollection: previousTraitCollection
51 | ) {
52 | previousBounds = .zero
53 | }
54 | }
55 |
56 | public override func prepareForReuse() {
57 | super.prepareForReuse()
58 |
59 | previousBounds = .zero
60 | cancellables?.forEach { $0.cancel() }
61 | }
62 |
63 | public override func layoutSubviews() {
64 | super.layoutSubviews()
65 |
66 | previousBounds = bounds.size
67 | }
68 |
69 | public override func preferredLayoutAttributesFitting(
70 | _ layoutAttributes: UICollectionViewLayoutAttributes
71 | ) -> UICollectionViewLayoutAttributes {
72 | let attributes = super.preferredLayoutAttributesFitting(layoutAttributes)
73 |
74 | guard let renderedContent else {
75 | return attributes
76 | }
77 |
78 | if KarrotListKitFeatureFlag.provider.isEnabled(for: .usesCachedViewSize),
79 | previousBounds == attributes.size {
80 | return attributes
81 | }
82 |
83 | let size = renderedContent.sizeThatFits(contentView.bounds.size)
84 |
85 | if renderedComponent != nil {
86 | onSizeChanged?(size)
87 | }
88 |
89 | attributes.frame.size = size
90 |
91 | return attributes
92 | }
93 | }
94 | #endif
95 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Adapter/CollectionViewAdapterConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// The configuration for the CollectionViewAdapter object.
9 | public struct CollectionViewAdapterConfiguration {
10 |
11 | /// Configure the RefreshControl of the CollectionView
12 | ///
13 | /// The default value is .disabled().
14 | public let refreshControl: RefreshControl
15 |
16 | /// If the changeSet count exceeds the batchUpdateInterruptCount,
17 | /// the UICollectionView operates with reloadData instead of animated updates.
18 | ///
19 | /// The default value is 100.
20 | public let batchUpdateInterruptCount: Int
21 |
22 | /// If true, uses UICollectionView's reconfigureItems API when updating items
23 | /// instead of reloadItems. This provides better performance by updating existing
24 | /// cells rather than recreating them.
25 | ///
26 | /// The default value is false.
27 | public let enablesReconfigureItems: Bool
28 |
29 | /// Initialize a new instance of `UICollectionViewAdapter`.
30 | ///
31 | /// - Parameters:
32 | /// - refreshControl: RefreshControl of the CollectionView
33 | /// - batchUpdateInterruptCount: maximum changeSet count that can be animated updates
34 | /// - enablesReconfigureItems: whether to use reconfigureItems API for item updates
35 | public init(
36 | refreshControl: RefreshControl = .disabled(),
37 | batchUpdateInterruptCount: Int = 100,
38 | enablesReconfigureItems: Bool = false
39 | ) {
40 | self.refreshControl = refreshControl
41 | self.batchUpdateInterruptCount = batchUpdateInterruptCount
42 | self.enablesReconfigureItems = enablesReconfigureItems
43 | }
44 | }
45 |
46 |
47 | // MARK: - RefreshControl
48 |
49 | extension CollectionViewAdapterConfiguration {
50 |
51 | /// Represents the information of the RefreshControl.
52 | public struct RefreshControl {
53 |
54 | /// Indicates whether the RefreshControl is applied or not.
55 | public let isEnabled: Bool
56 |
57 | // The tint color of the RefreshControl.
58 | public let tintColor: UIColor
59 |
60 | /// Use this function to enable the RefreshControl and set its tint color.
61 | public static func enabled(tintColor: UIColor) -> RefreshControl {
62 | .init(isEnabled: true, tintColor: tintColor)
63 | }
64 |
65 | /// Use this function to disable the RefreshControl.
66 | public static func disabled() -> RefreshControl {
67 | .init(isEnabled: false, tintColor: .clear)
68 | }
69 | }
70 | }
71 | #endif
72 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/SupplementaryView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// The `SupplementaryView` that represents a supplementary view in a `UICollectionView`.
9 | ///
10 | /// The framework supports commonly used header or footer.
11 | /// To represent the header or footer, you can add them to the `Section` using the `withHeader`, `withFooter` modifiers.
12 | /// The code below is a sample code.
13 | ///
14 | /// ```swift
15 | /// Section(id: UUID()) {
16 | /// ...
17 | /// }
18 | /// .withHeader(MyComponent())
19 | /// .withFooter(MyComponent())
20 | /// ```
21 | public struct SupplementaryView: Equatable, ListingViewEventHandler {
22 |
23 | /// A type-erased component for supplementary view.
24 | public let component: AnyComponent
25 |
26 | /// The kind of the supplementary view.
27 | public let kind: String
28 |
29 | /// The alignment of the supplementary view.
30 | public let alignment: NSRectAlignment
31 |
32 | let eventStorage = ListingViewEventStorage()
33 |
34 | // MARK: - Initializer
35 |
36 | /// The initializer method that creates a SupplementaryView.
37 | ///
38 | /// - Parameters:
39 | /// - kind: The kind of supplementary view to locate.
40 | /// - component: A type-erased component for supplementary view.
41 | /// - alignment: The alignment of the supplementary view.
42 | public init(kind: String, component: some Component, alignment: NSRectAlignment) {
43 | self.kind = kind
44 | self.component = AnyComponent(component)
45 | self.alignment = alignment
46 | }
47 |
48 | public static func == (lhs: SupplementaryView, rhs: SupplementaryView) -> Bool {
49 | lhs.component == rhs.component && lhs.kind == rhs.kind && lhs.alignment == rhs.alignment
50 | }
51 | }
52 |
53 | // MARK: - Event Handler
54 |
55 | extension SupplementaryView {
56 |
57 | /// Register a callback handler that will be called when the component is displayed on the screen.
58 | ///
59 | /// - Parameters:
60 | /// - handler: The callback handler when component is displayed on the screen.
61 | public func willDisplay(_ handler: @escaping (WillDisplayEvent.EventContext) -> Void) -> Self {
62 | registerEvent(WillDisplayEvent(handler: handler))
63 | }
64 |
65 | /// Registers a callback handler that will be called when the component is removed from the screen.
66 | ///
67 | /// - Parameters:
68 | /// - handler: The callback handler when the component is removed from the screen.
69 | public func didEndDisplaying(_ handler: @escaping (DidEndDisplayingEvent.EventContext) -> Void) -> Self {
70 | registerEvent(DidEndDisplayingEvent(handler: handler))
71 | }
72 | }
73 | #endif
74 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp/Samples/VerticalLayout/VerticalLayoutListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
3 | //
4 |
5 | import UIKit
6 |
7 | import KarrotListKit
8 |
9 | final class VerticalLayoutListView: UIView {
10 |
11 | // MARK: Const
12 |
13 | private enum Const {
14 | static let pageSize = 100
15 | static let maximumViewModelCount = 1000
16 | }
17 |
18 | // MARK: UICollectionView
19 |
20 | private let layoutAdapter = CollectionViewLayoutAdapter()
21 |
22 | private lazy var collectionView = UICollectionView(layoutAdapter: layoutAdapter)
23 |
24 | private lazy var collectionViewAdapter = CollectionViewAdapter(
25 | configuration: CollectionViewAdapterConfiguration(),
26 | collectionView: collectionView,
27 | layoutAdapter: layoutAdapter
28 | )
29 |
30 | // MARK: ViewModel
31 |
32 | private var viewModels: [VerticalLayoutItemComponent.ViewModel] = [] {
33 | didSet {
34 | guard viewModels != oldValue else { return }
35 | applyViewModels()
36 | }
37 | }
38 |
39 | // MARK: Init
40 |
41 | override init(frame: CGRect) {
42 | super.init(frame: frame)
43 | defineLayout()
44 | resetViewModels()
45 | }
46 |
47 | @available(*, unavailable)
48 | required init?(coder: NSCoder) {
49 | fatalError("init(coder:) has not been implemented")
50 | }
51 |
52 | // MARK: Configuration Methods
53 |
54 | private func defineLayout() {
55 | addSubview(collectionView)
56 | }
57 |
58 | private func resetViewModels() {
59 | viewModels = []
60 | appendViewModels()
61 | }
62 |
63 | private func appendViewModels() {
64 | guard viewModels.count < Const.maximumViewModelCount else { return }
65 | viewModels.append(contentsOf: (0.. SizeContext?
20 |
21 | /// Retrieves the size of a header.
22 | /// - Parameter hash: The hash value of the header.
23 | /// - Returns: The size context of the header.
24 | func headerSize(for hash: AnyHashable) -> SizeContext?
25 |
26 | /// Retrieves the size of a footer.
27 | /// - Parameter hash: The hash value of the footer.
28 | /// - Returns: The size context of the footer.
29 | func footerSize(for hash: AnyHashable) -> SizeContext?
30 |
31 | /// Sets the size of a cell.
32 | /// - Parameters:
33 | /// - size: The size context to set.
34 | /// - hash: The hash value of the cell.
35 | func setCellSize(_ size: SizeContext, for hash: AnyHashable)
36 |
37 | /// Sets the size of a header.
38 | /// - Parameters:
39 | /// - size: The size context to set.
40 | /// - hash: The hash value of the header.
41 | func setHeaderSize(_ size: SizeContext, for hash: AnyHashable)
42 |
43 | /// Sets the size of a footer.
44 | /// - Parameters:
45 | /// - size: The size context to set.
46 | /// - hash: The hash value of the footer.
47 | func setFooterSize(_ size: SizeContext, for hash: AnyHashable)
48 | }
49 |
50 | final class ComponentSizeStorageImpl: ComponentSizeStorage {
51 |
52 | /// A dictionary to store the sizes of cells.
53 | var cellSizeStore = [AnyHashable: SizeContext]()
54 |
55 | /// A dictionary to store the sizes of headers.
56 | var headerSizeStore = [AnyHashable: SizeContext]()
57 |
58 | /// A dictionary to store the sizes of footers.
59 | var footerSizeStore = [AnyHashable: SizeContext]()
60 |
61 | func cellSize(for hash: AnyHashable) -> SizeContext? {
62 | cellSizeStore[hash]
63 | }
64 |
65 | func headerSize(for hash: AnyHashable) -> SizeContext? {
66 | headerSizeStore[hash]
67 | }
68 |
69 | func footerSize(for hash: AnyHashable) -> SizeContext? {
70 | footerSizeStore[hash]
71 | }
72 |
73 | func setCellSize(_ size: SizeContext, for hash: AnyHashable) {
74 | cellSizeStore[hash] = size
75 | }
76 |
77 | func setHeaderSize(_ size: SizeContext, for hash: AnyHashable) {
78 | headerSizeStore[hash] = size
79 | }
80 |
81 | func setFooterSize(_ size: SizeContext, for hash: AnyHashable) {
82 | footerSizeStore[hash] = size
83 | }
84 | }
85 | #endif
86 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Extension/UICollectionView+Difference.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import DifferenceKit
6 |
7 | #if canImport(UIKit)
8 | import UIKit
9 |
10 | extension UICollectionView {
11 | func reload(
12 | using stagedChangeset: StagedChangeset,
13 | interrupt: ((Changeset) -> Bool)? = nil,
14 | setData: (C) -> Void,
15 | enablesReconfigureItems: Bool,
16 | completion: ((Bool) -> ())? = nil
17 | ) {
18 | if stagedChangeset.isEmpty {
19 | completion?(true)
20 | return
21 | }
22 |
23 | if case .none = window, let data = stagedChangeset.last?.data {
24 | setData(data)
25 | reloadData()
26 | layoutIfNeeded()
27 | completion?(true)
28 | return
29 | }
30 |
31 | for (index, changeset) in stagedChangeset.enumerated() {
32 | if let interrupt, interrupt(changeset), let data = stagedChangeset.last?.data {
33 | setData(data)
34 | reloadData()
35 | layoutIfNeeded()
36 | completion?(true)
37 | return
38 | }
39 |
40 | let isLastUpdate = index == (stagedChangeset.endIndex - 1)
41 |
42 | performBatchUpdates({
43 | setData(changeset.data)
44 |
45 | if !changeset.sectionDeleted.isEmpty {
46 | deleteSections(IndexSet(changeset.sectionDeleted))
47 | }
48 |
49 | if !changeset.sectionInserted.isEmpty {
50 | insertSections(IndexSet(changeset.sectionInserted))
51 | }
52 |
53 | if !changeset.sectionUpdated.isEmpty {
54 | reloadSections(IndexSet(changeset.sectionUpdated))
55 | }
56 |
57 | for (source, target) in changeset.sectionMoved {
58 | moveSection(source, toSection: target)
59 | }
60 |
61 | if !changeset.elementDeleted.isEmpty {
62 | deleteItems(at: changeset.elementDeleted.map { IndexPath(item: $0.element, section: $0.section) })
63 | }
64 |
65 | if !changeset.elementInserted.isEmpty {
66 | insertItems(at: changeset.elementInserted.map { IndexPath(item: $0.element, section: $0.section) })
67 | }
68 |
69 | if !changeset.elementUpdated.isEmpty {
70 | if #available(iOS 15.0, *), enablesReconfigureItems {
71 | reconfigureItems(at: changeset.elementUpdated.map { IndexPath(item: $0.element, section: $0.section) })
72 | } else {
73 | reloadItems(at: changeset.elementUpdated.map { IndexPath(item: $0.element, section: $0.section) })
74 | }
75 | }
76 |
77 | for (source, target) in changeset.elementMoved {
78 | moveItem(
79 | at: IndexPath(item: source.element, section: source.section),
80 | to: IndexPath(item: target.element, section: target.section)
81 | )
82 | }
83 | }, completion: isLastUpdate ? completion : nil)
84 | }
85 | }
86 | }
87 | #endif
88 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Component/Component.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// The Component is the smallest unit within the framework.
9 | /// It allows for the declarative representation of data and actions to be displayed on the screen.
10 | /// The component has an interface very similar to UIViewRepresentable. This similarity allows us to reduce the cost of migrating to SwiftUI in the future.
11 | public protocol Component {
12 |
13 | /// The type of the `ViewModel` associated with the component.
14 | /// The ViewModel must conform to `Equatable`.
15 | associatedtype ViewModel: Equatable
16 |
17 | /// The type of the `Content` that the component represents.
18 | associatedtype Content: UIView
19 |
20 | /// The type of the `Coordinator` associated with the component.
21 | /// By default, this is `Void`.
22 | associatedtype Coordinator = Void
23 |
24 | /// The ViewModel of the component.
25 | var viewModel: ViewModel { get }
26 |
27 | /// A reuse identifier for the component.
28 | var reuseIdentifier: String { get }
29 |
30 | /// The layout mode of the component's content.
31 | var layoutMode: ContentLayoutMode { get }
32 |
33 | /// Creates the content object and configures its initial state.
34 | ///
35 | /// - Parameter coordinator: The coordinator to use for rendering the content.
36 | /// - Returns: The rendered content.
37 | func renderContent(coordinator: Coordinator) -> Content
38 |
39 | /// Updates the state of the specified content with new information
40 | ///
41 | /// - Parameters:
42 | /// - content: The content to render.
43 | /// - coordinator: The coordinator to use for rendering.
44 | func render(in content: Content, coordinator: Coordinator)
45 |
46 | /// Lays out the specified content in the given container view.
47 | ///
48 | /// - Parameters:
49 | /// - content: The content to layout.
50 | /// - container: The view to layout the content in.
51 | func layout(content: Content, in container: UIView)
52 |
53 | /// Creates the custom instance that you use to communicate changes from your view to other parts of your SwiftUI interface.
54 | ///
55 | /// If your view doesn’t interact with SwiftUI interface, providing a coordinator is unnecessary.
56 | /// - Returns: A new `Coordinator` instance.
57 | func makeCoordinator() -> Coordinator
58 | }
59 |
60 | extension Component {
61 | public var reuseIdentifier: String {
62 | String(reflecting: Self.self)
63 | }
64 | }
65 |
66 | extension Component where Coordinator == Void {
67 | public func makeCoordinator() -> Coordinator {
68 | ()
69 | }
70 | }
71 |
72 | extension Component where Content: UIView {
73 | public func layout(content: Content, in container: UIView) {
74 | content.translatesAutoresizingMaskIntoConstraints = false
75 | container.addSubview(content)
76 | NSLayoutConstraint.activate([
77 | content.topAnchor.constraint(equalTo: container.topAnchor),
78 | content.leadingAnchor.constraint(equalTo: container.leadingAnchor),
79 | content.bottomAnchor.constraint(equalTo: container.bottomAnchor),
80 | content.trailingAnchor.constraint(equalTo: container.trailingAnchor),
81 | ])
82 | }
83 | }
84 | #endif
85 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/Utils/ChunkedTests.swift:
--------------------------------------------------------------------------------
1 | //===----------------------------------------------------------------------===//
2 | //
3 | // This source file is part of the Swift Algorithms open source project
4 | //
5 | // Copyright (c) 2020 Apple Inc. and the Swift project authors
6 | // Licensed under Apache License v2.0 with Runtime Library Exception
7 | //
8 | // See https://swift.org/LICENSE.txt for license information
9 | //
10 | //===----------------------------------------------------------------------===//
11 |
12 | import XCTest
13 |
14 | @testable import KarrotListKit
15 |
16 | final class ChunkedTests: XCTestCase {
17 |
18 | //===----------------------------------------------------------------------===//
19 | // Tests for `chunks(ofCount:)`
20 | //===----------------------------------------------------------------------===//
21 |
22 | func testChunksOfCount() {
23 | XCTAssertEqual([Int]().chunks(ofCount: 1), [])
24 | XCTAssertEqual([Int]().chunks(ofCount: 5), [])
25 |
26 | let collection1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
27 | XCTAssertEqual(collection1.chunks(ofCount: 1), [[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]])
28 | XCTAssertEqual(collection1.chunks(ofCount: 3), [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]])
29 | XCTAssertEqual(collection1.chunks(ofCount: 5), [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
30 | XCTAssertEqual(collection1.chunks(ofCount: 11), [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]])
31 |
32 | let collection2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
33 | XCTAssertEqual(collection2.chunks(ofCount: 3), [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11]])
34 | }
35 |
36 | func testChunksOfCountBidirectional() {
37 | let collection1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
38 |
39 | XCTAssertEqual(collection1.chunks(ofCount: 1).reversed(), [[10], [9], [8], [7], [6], [5], [4], [3], [2], [1]])
40 | XCTAssertEqual(collection1.chunks(ofCount: 3).reversed(), [[10], [7, 8, 9], [4, 5, 6], [1, 2, 3]])
41 | XCTAssertEqual(collection1.chunks(ofCount: 5).reversed(), [[6, 7, 8, 9, 10], [1, 2, 3, 4, 5]])
42 | XCTAssertEqual(collection1.chunks(ofCount: 11).reversed(), [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]])
43 |
44 | let collection2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
45 | XCTAssertEqual(collection2.chunks(ofCount: 3).reversed(), [[10, 11], [7, 8, 9], [4, 5, 6], [1, 2, 3]])
46 | }
47 |
48 | func testChunksOfCountCount() {
49 | XCTAssertEqual([Int]().chunks(ofCount: 1).count, 0)
50 | XCTAssertEqual([Int]().chunks(ofCount: 5).count, 0)
51 |
52 | let collection1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
53 | XCTAssertEqual(collection1.chunks(ofCount: 1).count, 10)
54 | XCTAssertEqual(collection1.chunks(ofCount: 3).count, 4)
55 | XCTAssertEqual(collection1.chunks(ofCount: 5).count, 2)
56 | XCTAssertEqual(collection1.chunks(ofCount: 11).count, 1)
57 |
58 | let collection2 = (1...50).map { $0 }
59 | XCTAssertEqual(collection2.chunks(ofCount: 9).count, 6)
60 | }
61 |
62 | func testEmptyChunksOfCountTraversal() {
63 | let emptyChunks = [Int]().chunks(ofCount: 1)
64 | XCTAssertTrue(emptyChunks.isEmpty)
65 | }
66 |
67 | func testChunksOfCountTraversal() {
68 | for i in 1...10 {
69 | let range = 1...50
70 | let chunks = range.chunks(ofCount: i)
71 | let expectedCount = range.count / i + (range.count % i).signum()
72 | XCTAssertEqual(chunks.count, expectedCount)
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/KarrotListKitMacros/Supports/AccessLevelModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessLevelModifier.swift
3 | // KarrotListKit
4 | //
5 | // Created by Daangn Jaxtyn on 7/18/25.
6 | //
7 |
8 | import Foundation
9 |
10 | import SwiftSyntax
11 |
12 | enum AccessLevelModifier: String, Comparable, CaseIterable, Sendable {
13 | case `private`
14 | case `fileprivate`
15 | case `internal`
16 | case `package`
17 | case `public`
18 | case `open`
19 |
20 | var keyword: Keyword {
21 | switch self {
22 | case .private:
23 | .private
24 | case .fileprivate:
25 | .fileprivate
26 | case .internal:
27 | .internal
28 | case .package:
29 | .package
30 | case .public:
31 | .public
32 | case .open:
33 | .open
34 | }
35 | }
36 |
37 | static func < (lhs: AccessLevelModifier, rhs: AccessLevelModifier) -> Bool {
38 | guard
39 | let lhs = Self.allCases.firstIndex(of: lhs),
40 | let rhs = Self.allCases.firstIndex(of: rhs)
41 | else {
42 | return false
43 | }
44 | return lhs < rhs
45 | }
46 |
47 | static func stringValue(from declaration: some DeclGroupSyntax) -> String {
48 | let accessLevel =
49 | if let protocolDecl = declaration.as(ProtocolDeclSyntax.self) {
50 | protocolDecl.accessLevel
51 | } else if let classDecl = declaration.as(ClassDeclSyntax.self) {
52 | classDecl.accessLevel
53 | } else if let structDecl = declaration.as(StructDeclSyntax.self) {
54 | structDecl.accessLevel
55 | } else if let enumDecl = declaration.as(EnumDeclSyntax.self) {
56 | enumDecl.accessLevel
57 | } else {
58 | AccessLevelModifier.internal
59 | }
60 |
61 | guard accessLevel != .internal else { return "" }
62 |
63 | // Change to public if access level is open
64 | if accessLevel == .open {
65 | return "\(AccessLevelModifier.public.rawValue) "
66 | }
67 |
68 | return "\(accessLevel.rawValue) "
69 | }
70 | }
71 |
72 | public protocol AccessLevelSyntax {
73 | var parent: Syntax? { get }
74 | var modifiers: DeclModifierListSyntax { get set }
75 | }
76 |
77 | extension AccessLevelSyntax {
78 | var accessLevelModifiers: [AccessLevelModifier]? {
79 | get {
80 | let accessLevels = modifiers.lazy.compactMap { AccessLevelModifier(rawValue: $0.name.text) }
81 | return accessLevels.isEmpty ? nil : Array(accessLevels)
82 | }
83 | set {
84 | guard let newModifiers = newValue else {
85 | modifiers = []
86 | return
87 | }
88 | let newModifierKeywords = newModifiers.map { DeclModifierSyntax(name: .keyword($0.keyword)) }
89 | let filteredModifiers = modifiers.filter {
90 | AccessLevelModifier(rawValue: $0.name.text) == nil
91 | }
92 | modifiers = filteredModifiers + newModifierKeywords
93 | }
94 | }
95 | }
96 |
97 | protocol DeclGroupAccessLevelSyntax: AccessLevelSyntax {}
98 |
99 | extension DeclGroupAccessLevelSyntax {
100 | public var accessLevel: AccessLevelModifier {
101 | accessLevelModifiers?.first ?? .internal
102 | }
103 | }
104 |
105 | extension ProtocolDeclSyntax: DeclGroupAccessLevelSyntax {}
106 | extension ClassDeclSyntax: DeclGroupAccessLevelSyntax {}
107 | extension StructDeclSyntax: DeclGroupAccessLevelSyntax {}
108 | extension EnumDeclSyntax: DeclGroupAccessLevelSyntax {}
109 | extension VariableDeclSyntax: DeclGroupAccessLevelSyntax {}
110 |
--------------------------------------------------------------------------------
/Sources/KarrotListKitMacros/AddComponentModifierMacro.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Created by Daangn Jaxtyn on 7/18/25.
3 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
4 | //
5 |
6 | import Foundation
7 | import SwiftSyntax
8 | import SwiftSyntaxBuilder
9 | import SwiftSyntaxMacros
10 |
11 | public struct AddComponentModifierMacro: PeerMacro {
12 | public static func expansion(
13 | of node: AttributeSyntax,
14 | providingPeersOf declaration: some DeclSyntaxProtocol,
15 | in context: some MacroExpansionContext
16 | ) throws -> [DeclSyntax] {
17 | #if canImport(SwiftSyntax600)
18 | guard
19 | let structDecl = context.lexicalContext.compactMap({ $0.as(StructDeclSyntax.self) }).first
20 | else {
21 | throw KarrotListKitMacroError(message: "@AddComponentModifier can only be used on properties inside structs")
22 | }
23 |
24 | guard
25 | let varDecl = declaration.as(VariableDeclSyntax.self),
26 | varDecl.bindingSpecifier.tokenKind == .keyword(.var)
27 | else {
28 | throw KarrotListKitMacroError(
29 | message: "@AddComponentModifier can only be applied to variable property"
30 | )
31 | }
32 |
33 | guard
34 | let binding = varDecl.bindings.first,
35 | let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
36 | let typeAnnotation = binding.typeAnnotation,
37 | let optionalType = typeAnnotation.type.as(OptionalTypeSyntax.self),
38 | let functionType = convertToFunctionType(from: optionalType)
39 | else {
40 | throw KarrotListKitMacroError(
41 | message: "@AddComponentModifier can only be applied to optional closure properties"
42 | )
43 | }
44 |
45 | let propertyName = identifier.identifier.text
46 | let accessLevelString = extractAccessLevel(from: structDecl)
47 | let methodName = propertyName.replacingOccurrences(of: "Handler", with: "")
48 | let handlerType = functionType.description.trimmingCharacters(in: .whitespaces)
49 |
50 | let componentModifier = """
51 | \(accessLevelString)func \(methodName)(_ handler: @escaping \(handlerType)) -> Self {
52 | var copy = self
53 | copy.\(propertyName) = handler
54 | return copy
55 | }
56 | """
57 |
58 | return [
59 | DeclSyntax(stringLiteral: componentModifier),
60 | ]
61 | #else
62 | throw KarrotListKitMacroError(
63 | message: "@AddComponentModifier macro requires SwiftSyntax 600.0.0 or later."
64 | )
65 | #endif
66 | }
67 |
68 | private static func convertToFunctionType(
69 | from optionalType: OptionalTypeSyntax
70 | ) -> FunctionTypeSyntax? {
71 | guard
72 | let tupleType = optionalType.wrappedType.as(TupleTypeSyntax.self),
73 | tupleType.elements.count == 1,
74 | let firstElement = tupleType.elements.first
75 | else {
76 | return optionalType.wrappedType.as(FunctionTypeSyntax.self)
77 | }
78 |
79 | return firstElement.type.as(FunctionTypeSyntax.self)
80 | }
81 |
82 | private static func extractAccessLevel(from structDecl: StructDeclSyntax) -> String {
83 | let structAccessLevel = structDecl.modifiers.first(where: { modifier in
84 | modifier.name.tokenKind == .keyword(.public) ||
85 | modifier.name.tokenKind == .keyword(.internal) ||
86 | modifier.name.tokenKind == .keyword(.fileprivate) ||
87 | modifier.name.tokenKind == .keyword(.private)
88 | })
89 |
90 | guard let structAccessLevel else { return "" }
91 | return structAccessLevel.name.text + " "
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Cell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import DifferenceKit
6 |
7 | #if canImport(UIKit)
8 | import UIKit
9 |
10 | /// The `Cell` that representing a UICollectionViewCell.
11 | public struct Cell: Identifiable, ListingViewEventHandler {
12 |
13 | /// The identifier for `Cell`
14 | public let id: AnyHashable
15 |
16 | /// A type-erased component for cell
17 | public let component: AnyComponent
18 |
19 | let eventStorage = ListingViewEventStorage()
20 |
21 | // MARK: - Initializer
22 |
23 | /// The initializer method that creates a Cell.
24 | ///
25 | /// - Parameters:
26 | /// - id: The identifier that identifies the Cell.
27 | /// - component: A type-erased component for cell.
28 | public init(id: some Hashable, component: some Component) {
29 | self.id = id
30 | self.component = AnyComponent(component)
31 | }
32 |
33 | /// The initializer method that creates a Cell.
34 | ///
35 | /// - Parameters:
36 | /// - id: The identifier that identifies the Cell.
37 | /// - component: A type-erased component for cell.
38 | public init(component: some IdentifiableComponent) {
39 | self.id = component.id
40 | self.component = AnyComponent(component)
41 | }
42 | }
43 |
44 | // MARK: - Event Handler
45 |
46 | extension Cell {
47 |
48 | /// Register a callback handler that will be called when the cell was selected
49 | ///
50 | /// - Parameters:
51 | /// - handler: The callback handler for select event
52 | public func didSelect(_ handler: @escaping (DidSelectEvent.EventContext) -> Void) -> Self {
53 | registerEvent(DidSelectEvent(handler: handler))
54 | }
55 |
56 | /// Register a callback handler that will be called when the cell being added
57 | ///
58 | /// - Parameters:
59 | /// - handler: The callback handler for will display event
60 | public func willDisplay(_ handler: @escaping (WillDisplayEvent.EventContext) -> Void) -> Self {
61 | registerEvent(WillDisplayEvent(handler: handler))
62 | }
63 |
64 | /// Register a callback handler that will be called when the cell was removed.
65 | ///
66 | /// - Parameters:
67 | /// - handler: The callback handler for did end display event
68 | public func didEndDisplay(_ handler: @escaping (DidEndDisplayingEvent.EventContext) -> Void) -> Self {
69 | registerEvent(DidEndDisplayingEvent(handler: handler))
70 | }
71 |
72 | /// Register a callback handler that will be called when the cell was highlighted.
73 | ///
74 | /// - Parameters:
75 | /// - handler: The callback handler for highlight event
76 | public func onHighlight(_ handler: @escaping (HighlightEvent.EventContext) -> Void) -> Self {
77 | registerEvent(HighlightEvent(handler: handler))
78 | }
79 |
80 | /// Register a callback handler that will be called when the cell was unhighlighted.
81 | ///
82 | /// - Parameters:
83 | /// - handler: The callback handler for unhighlight event
84 | public func onUnhighlight(_ handler: @escaping (UnhighlightEvent.EventContext) -> Void) -> Self {
85 | registerEvent(UnhighlightEvent(handler: handler))
86 | }
87 | }
88 |
89 | // MARK: - Hashable
90 |
91 | extension Cell: Hashable {
92 | public func hash(into hasher: inout Hasher) {
93 | hasher.combine(id)
94 | }
95 |
96 | public static func == (lhs: Cell, rhs: Cell) -> Bool {
97 | lhs.id == rhs.id && lhs.component == rhs.component
98 | }
99 | }
100 |
101 | // MARK: - Differentiable
102 |
103 | extension Cell: Differentiable {
104 | public var differenceIdentifier: AnyHashable {
105 | id
106 | }
107 |
108 | public func isContentEqual(to source: Self) -> Bool {
109 | self == source
110 | }
111 | }
112 | #endif
113 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Adapter/CollectionViewLayoutAdaptable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// A protocol that manage data and size information for List
9 | ///
10 | /// A data source object manages the data in your collection view.
11 | /// Additionally, the data source object should be able to cache and manage size information for cells / supplementary views.
12 | ///
13 | /// - Note: `CollectionViewAdapter` conform this protocol and manages data and size information internally.
14 | /// so we generally do not implement this protocol.
15 | public protocol CollectionViewLayoutAdapterDataSource: AnyObject {
16 |
17 | /// Returns the section at the index.
18 | /// - Parameter index: The index of the section to return.
19 | /// - Returns: The section at the index.
20 | func sectionItem(at index: Int) -> Section?
21 |
22 | /// Returns the ComponentSizeStorage that managing the cached size information.
23 | /// - Returns: The ComponentSizeStorage that managing the cached size information.
24 | func sizeStorage() -> ComponentSizeStorage
25 | }
26 |
27 | /// The `CollectionViewLayoutAdaptable` interface serves as an adapter between the UICollectionViewCompositionalLayout logic and the `KarrotListKit` layout logic
28 | ///
29 | /// The sectionProvider in `UICollectionViewCompositionalLayout.init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)` is mapped to the sectionLayout method in the interface.
30 | /// We can implement `UICollectionViewCompositionalLayout` through the implementation of the sectionLayout method.
31 | ///
32 | /// - Note: `CollectionViewLayoutAdapter` conform this protocol so we generally do not implement this protocol.
33 | public protocol CollectionViewLayoutAdaptable: AnyObject {
34 |
35 | /// The data source needed to create NSCollectionLayoutSection.
36 | var dataSource: CollectionViewLayoutAdapterDataSource? { get set }
37 |
38 | /// The method to create NSCollectionLayoutSection.
39 | /// - Parameters:
40 | /// - index: The index of the section to create.
41 | /// - environment: The layout environment information.
42 | /// - Returns: The created NSCollectionLayoutSection.
43 | func sectionLayout(
44 | index: Int,
45 | environment: NSCollectionLayoutEnvironment
46 | ) -> NSCollectionLayoutSection?
47 | }
48 |
49 | /// This object is the default implementation for CollectionViewLayoutAdaptable.
50 | ///
51 | /// The initialize has been extended to accept CollectionViewLayoutAdaptable at the time of UICollectionView.init.
52 | /// If you want to inject UICollectionViewCompositionalLayout directly, please refer to the code below.
53 | /// ```swift
54 | /// UICollectionView(
55 | /// frame: .zero,
56 | /// collectionViewLayout: UICollectionViewCompositionalLayout(
57 | /// sectionProvider: layoutAdapter.sectionLayout
58 | /// )
59 | /// )
60 | /// ```
61 | public class CollectionViewLayoutAdapter: CollectionViewLayoutAdaptable {
62 |
63 | /// The data source needed to create NSCollectionLayoutSection.
64 | public weak var dataSource: CollectionViewLayoutAdapterDataSource?
65 |
66 | public init() {}
67 |
68 | /// The method to create NSCollectionLayoutSection.
69 | /// - Parameters:
70 | /// - index: The index of the section to create.
71 | /// - environment: The layout environment information.
72 | /// - Returns: The created NSCollectionLayoutSection.
73 | public func sectionLayout(
74 | index: Int,
75 | environment: NSCollectionLayoutEnvironment
76 | ) -> NSCollectionLayoutSection? {
77 | guard let dataSource else {
78 | return nil
79 | }
80 |
81 | guard let sectionItem = dataSource.sectionItem(at: index), !sectionItem.cells.isEmpty else {
82 | return nil
83 | }
84 |
85 | return sectionItem.layout(
86 | index: index,
87 | environment: environment,
88 | sizeStorage: dataSource.sizeStorage()
89 | )
90 | }
91 | }
92 | #endif
93 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Layout/VerticalLayout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This layout supports vertical scrolling.
9 | ///
10 | /// If the width of the Cell is the same as the width of the CollectionView and the height is fit to the size of the content, this layout can be used to vertical scrolling style UI.
11 | /// - Note: when using a vertical layout, the layout mode of the component must be Flexible High.
12 | public struct VerticalLayout: CompositionalLayoutSectionFactory {
13 |
14 | private let spacing: CGFloat
15 | private var sectionInsets: NSDirectionalEdgeInsets?
16 | private var headerPinToVisibleBounds: Bool?
17 | private var footerPinToVisibleBounds: Bool?
18 | private var visibleItemsInvalidationHandler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
19 |
20 | /// Initializes a new vertical layout.
21 | /// - Parameter spacing: The spacing between items in the layout. Default value is `0.0`.
22 | public init(spacing: CGFloat = 0.0) {
23 | self.spacing = spacing
24 | }
25 |
26 | /// Creates a layout for a section.
27 | public func makeSectionLayout() -> SectionLayout? {
28 | { context -> NSCollectionLayoutSection? in
29 | let group = NSCollectionLayoutGroup.vertical(
30 | layoutSize: .init(
31 | widthDimension: .fractionalWidth(1.0),
32 | heightDimension: .estimated(context.environment.container.contentSize.height)
33 | ),
34 | subitems: layoutCellItems(cells: context.section.cells, sizeStorage: context.sizeStorage)
35 | )
36 | group.interItemSpacing = .fixed(spacing)
37 |
38 | let section = NSCollectionLayoutSection(group: group)
39 | if let sectionInsets {
40 | section.contentInsets = sectionInsets
41 | }
42 |
43 | if let visibleItemsInvalidationHandler {
44 | section.visibleItemsInvalidationHandler = visibleItemsInvalidationHandler
45 | }
46 |
47 | let headerItem = layoutHeaderItem(section: context.section, sizeStorage: context.sizeStorage)
48 | if let headerPinToVisibleBounds {
49 | headerItem?.pinToVisibleBounds = headerPinToVisibleBounds
50 | }
51 |
52 | let footerItem = layoutFooterItem(section: context.section, sizeStorage: context.sizeStorage)
53 | if let footerPinToVisibleBounds {
54 | footerItem?.pinToVisibleBounds = footerPinToVisibleBounds
55 | }
56 |
57 | section.boundarySupplementaryItems = [
58 | headerItem,
59 | footerItem,
60 | ].compactMap { $0 }
61 |
62 | return section
63 | }
64 | }
65 |
66 | /// Sets the insets for the section.
67 | ///
68 | /// - Parameters:
69 | /// - insets: insets for section
70 | public func insets(_ insets: NSDirectionalEdgeInsets?) -> Self {
71 | var copy = self
72 | copy.sectionInsets = insets
73 | return copy
74 | }
75 |
76 | /// Sets whether the header should pin to visible bounds.
77 | ///
78 | /// - Parameters:
79 | /// - pinToVisibleBounds: A Boolean value that indicates whether a header is pinned to the top
80 | public func headerPinToVisibleBounds(_ pinToVisibleBounds: Bool?) -> Self {
81 | var copy = self
82 | copy.headerPinToVisibleBounds = pinToVisibleBounds
83 | return copy
84 | }
85 |
86 | /// Sets whether the footer should pin to visible bounds.
87 | ///
88 | /// - Parameters:
89 | /// - pinToVisibleBounds: A Boolean value that indicates whether a footer is pinned to the bottom
90 | public func footerPinToVisibleBounds(_ pinToVisibleBounds: Bool?) -> Self {
91 | var copy = self
92 | copy.footerPinToVisibleBounds = pinToVisibleBounds
93 | return copy
94 | }
95 |
96 | /// Sets the handler for invalidating visible items.
97 | ///
98 | /// - Parameters:
99 | /// - handler: visible items invalidation handler
100 | public func withVisibleItemsInvalidationHandler(
101 | _ handler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
102 | ) -> Self {
103 | var copy = self
104 | copy.visibleItemsInvalidationHandler = handler
105 | return copy
106 | }
107 | }
108 | #endif
109 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp/Samples/VerticalLayout/VerticalLayoutItemView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
3 | //
4 |
5 | import UIKit
6 |
7 | import FlexLayout
8 | import PinLayout
9 |
10 | final class VerticalLayoutItemView: UIView {
11 |
12 | // MARK: ViewModel
13 |
14 | struct ViewModel: Equatable {
15 |
16 | let id: UUID
17 | var title: String?
18 | var subtitle: String?
19 |
20 | init(
21 | id: UUID = .init(),
22 | title: String? = nil,
23 | subtitle: String? = nil
24 | ) {
25 | self.id = id
26 | self.title = title
27 | self.subtitle = subtitle
28 | }
29 |
30 | static func random() -> Self {
31 | .init(
32 | title: .randomWords(count: .random(in: 4...10), wordLength: 4...10),
33 | subtitle: Bool.random() ? .randomWords(count: .random(in: 4...10), wordLength: 4...10) : nil
34 | )
35 | }
36 | }
37 |
38 | var viewModel: ViewModel {
39 | didSet {
40 | guard viewModel != oldValue else { return }
41 | applyViewModel()
42 | }
43 | }
44 |
45 | // MARK: Subviews
46 |
47 | private let rootFlexContainer = UIView()
48 |
49 | private let titleLabel: UILabel = {
50 | let label = UILabel()
51 | label.numberOfLines = 0
52 | label.adjustsFontForContentSizeCategory = true
53 | label.font = .preferredFont(forTextStyle: .body)
54 | label.textColor = .label
55 | return label
56 | }()
57 |
58 | private let subtitleLabel: UILabel = {
59 | let label = UILabel()
60 | label.numberOfLines = 0
61 | label.adjustsFontForContentSizeCategory = true
62 | label.font = .preferredFont(forTextStyle: .subheadline)
63 | label.textColor = .secondaryLabel
64 | return label
65 | }()
66 |
67 | private let separator: UIView = {
68 | let separator = UIView()
69 | separator.backgroundColor = .separator
70 | return separator
71 | }()
72 |
73 | // MARK: Init
74 |
75 | init(viewModel: ViewModel) {
76 | self.viewModel = viewModel
77 | super.init(frame: .zero)
78 | defineLayout()
79 | applyViewModel()
80 | }
81 |
82 | @available(*, unavailable)
83 | required init?(coder: NSCoder) {
84 | fatalError("init(coder:) has not been implemented")
85 | }
86 |
87 | // MARK: Configuration Methods
88 |
89 | private func defineLayout() {
90 | addSubview(rootFlexContainer)
91 | rootFlexContainer.flex
92 | .direction(.column)
93 | .paddingHorizontal(16.0)
94 | .paddingVertical(8.0)
95 | .define {
96 | $0.addItem(titleLabel)
97 | $0.addItem(subtitleLabel)
98 | $0.addItem(separator)
99 | .position(.absolute)
100 | .start(16.0)
101 | .end(0.0)
102 | .bottom(0.0)
103 | .height(1.0 / traitCollection.displayScale)
104 | }
105 | }
106 |
107 | private func applyViewModel() {
108 | titleLabel.text = viewModel.title
109 | subtitleLabel.text = viewModel.subtitle
110 |
111 | titleLabel.flex.markDirty()
112 | subtitleLabel.flex.markDirty()
113 |
114 | titleLabel.flex.display(viewModel.title != nil ? .flex : .none)
115 | subtitleLabel.flex.display(viewModel.subtitle != nil ? .flex : .none)
116 | }
117 |
118 | // MARK: UIView Methods
119 |
120 | override func sizeThatFits(_ size: CGSize) -> CGSize {
121 | rootFlexContainer.flex.sizeThatFits(
122 | size: CGSize(
123 | width: size.width,
124 | height: .nan
125 | )
126 | )
127 | }
128 |
129 | override func layoutSubviews() {
130 | super.layoutSubviews()
131 | rootFlexContainer.pin.all()
132 | rootFlexContainer.flex.layout()
133 | }
134 |
135 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
136 | super.traitCollectionDidChange(previousTraitCollection)
137 | if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
138 | titleLabel.flex.markDirty()
139 | subtitleLabel.flex.markDirty()
140 | }
141 | }
142 | }
143 |
144 | @available(iOS 17.0, *)
145 | #Preview {
146 | VerticalLayoutItemView(
147 | viewModel: .init(
148 | title: "Title",
149 | subtitle: "Subtitle"
150 | )
151 | )
152 | }
153 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Layout/HorizontalLayout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This layout supports horizontal scrolling.
9 | ///
10 | /// If the width and height of the Cell are both fit to the size of the content, this layout can be used to horizontal scrolling style UI.
11 | /// - Note: when using a horizontal layout, the layout mode of the component must be Fit Content.
12 | public struct HorizontalLayout: CompositionalLayoutSectionFactory {
13 |
14 | private let spacing: CGFloat
15 | private let scrollingBehavior: UICollectionLayoutSectionOrthogonalScrollingBehavior
16 | private var sectionInsets: NSDirectionalEdgeInsets?
17 | private var headerPinToVisibleBounds: Bool?
18 | private var footerPinToVisibleBounds: Bool?
19 | private var visibleItemsInvalidationHandler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
20 |
21 | /// Initializes a new horizontal layout.
22 | /// - Parameters:
23 | /// - spacing: The spacing between items in the layout. Default value is 0.0.
24 | /// - scrollingBehavior: The behavior of the layout when scrolling. Default value is .continuous.
25 | public init(
26 | spacing: CGFloat = 0.0,
27 | scrollingBehavior: UICollectionLayoutSectionOrthogonalScrollingBehavior = .continuous
28 | ) {
29 | self.spacing = spacing
30 | self.scrollingBehavior = scrollingBehavior
31 | }
32 |
33 | /// Creates a layout for a section.
34 | public func makeSectionLayout() -> SectionLayout? {
35 | { context -> NSCollectionLayoutSection? in
36 | let group = NSCollectionLayoutGroup.horizontal(
37 | layoutSize: .init(
38 | widthDimension: .estimated(context.environment.container.contentSize.width),
39 | heightDimension: .estimated(context.environment.container.contentSize.height)
40 | ),
41 | subitems: layoutCellItems(cells: context.section.cells, sizeStorage: context.sizeStorage)
42 | )
43 | group.interItemSpacing = .fixed(spacing)
44 |
45 |
46 | let section = NSCollectionLayoutSection(group: group)
47 | if let sectionInsets {
48 | section.contentInsets = sectionInsets
49 | }
50 |
51 | if let visibleItemsInvalidationHandler {
52 | section.visibleItemsInvalidationHandler = visibleItemsInvalidationHandler
53 | }
54 |
55 | section.orthogonalScrollingBehavior = scrollingBehavior
56 |
57 | let headerItem = layoutHeaderItem(section: context.section, sizeStorage: context.sizeStorage)
58 | if let headerPinToVisibleBounds {
59 | headerItem?.pinToVisibleBounds = headerPinToVisibleBounds
60 | }
61 |
62 | let footerItem = layoutFooterItem(section: context.section, sizeStorage: context.sizeStorage)
63 | if let footerPinToVisibleBounds {
64 | footerItem?.pinToVisibleBounds = footerPinToVisibleBounds
65 | }
66 |
67 | section.boundarySupplementaryItems = [
68 | headerItem,
69 | footerItem,
70 | ].compactMap { $0 }
71 |
72 | return section
73 | }
74 | }
75 |
76 | /// Sets the insets for the section.
77 | ///
78 | /// - Parameters:
79 | /// - insets: insets for section
80 | public func insets(_ insets: NSDirectionalEdgeInsets?) -> Self {
81 | var copy = self
82 | copy.sectionInsets = insets
83 | return copy
84 | }
85 |
86 | /// Sets whether the header should pin to visible bounds.
87 | ///
88 | /// - Parameters:
89 | /// - pinToVisibleBounds: A Boolean value that indicates whether a header is pinned to the top
90 | public func headerPinToVisibleBounds(_ pinToVisibleBounds: Bool?) -> Self {
91 | var copy = self
92 | copy.headerPinToVisibleBounds = pinToVisibleBounds
93 | return copy
94 | }
95 |
96 | /// Sets whether the footer should pin to visible bounds.
97 | ///
98 | /// - Parameters:
99 | /// - pinToVisibleBounds: A Boolean value that indicates whether a footer is pinned to the bottom
100 | public func footerPinToVisibleBounds(_ pinToVisibleBounds: Bool?) -> Self {
101 | var copy = self
102 | copy.footerPinToVisibleBounds = pinToVisibleBounds
103 | return copy
104 | }
105 |
106 | /// Sets the handler for invalidating visible items.
107 | ///
108 | /// - Parameters:
109 | /// - handler: visible items invalidation handler
110 | public func withVisibleItemsInvalidationHandler(
111 | _ handler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
112 | ) -> Self {
113 | var copy = self
114 | copy.visibleItemsInvalidationHandler = handler
115 | return copy
116 | }
117 | }
118 | #endif
119 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/CollectionViewLayoutAdapterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Combine
6 | #if canImport(UIKit)
7 | import UIKit
8 | #endif
9 |
10 | import XCTest
11 |
12 | @testable import KarrotListKit
13 |
14 | #if canImport(UIKit)
15 | final class CollectionViewLayoutAdapterTests: XCTestCase {
16 |
17 | final class NSCollectionLayoutEnvironmentDummy: NSObject, NSCollectionLayoutEnvironment {
18 | var container: NSCollectionLayoutContainer { fatalError() }
19 | var traitCollection: UITraitCollection { .init() }
20 | }
21 |
22 | final class ComponentSizeStorageDummy: ComponentSizeStorage {
23 |
24 | func cellSize(for hash: AnyHashable) -> SizeContext? {
25 | nil
26 | }
27 |
28 | func headerSize(for hash: AnyHashable) -> SizeContext? {
29 | nil
30 | }
31 |
32 | func footerSize(for hash: AnyHashable) -> SizeContext? {
33 | nil
34 | }
35 |
36 | func setCellSize(_ size: SizeContext, for hash: AnyHashable) {}
37 |
38 | func setHeaderSize(_ size: SizeContext, for hash: AnyHashable) {}
39 |
40 | func setFooterSize(_ size: SizeContext, for hash: AnyHashable) {}
41 | }
42 |
43 | final class CollectionViewLayoutAdapterDataSourceStub: CollectionViewLayoutAdapterDataSource {
44 |
45 | var section: Section?
46 | func sectionItem(at index: Int) -> Section? {
47 | section
48 | }
49 |
50 | var sizeStorageStub: ComponentSizeStorage!
51 | func sizeStorage() -> ComponentSizeStorage {
52 | sizeStorageStub
53 | }
54 | }
55 |
56 | func sut() -> CollectionViewLayoutAdapter {
57 | CollectionViewLayoutAdapter()
58 | }
59 | }
60 |
61 | // MARK: - Initializes
62 |
63 | extension CollectionViewLayoutAdapterTests {
64 |
65 | func test_given_no_setup_when_sectionLayout_then_return_nil() {
66 | // given
67 | let sut = sut()
68 |
69 | // when
70 | let sectionLayout = sut.sectionLayout(index: 0, environment: NSCollectionLayoutEnvironmentDummy())
71 |
72 | // then
73 | XCTAssertNil(sectionLayout)
74 | }
75 |
76 | func test_given_nil_section_when_sectionLayout_then_return_nil() {
77 | let dataSource = CollectionViewLayoutAdapterDataSourceStub()
78 | dataSource.section = nil
79 |
80 | let sut = sut()
81 | sut.dataSource = dataSource
82 |
83 | // when
84 | let sectionLayout = sut.sectionLayout(index: 0, environment: NSCollectionLayoutEnvironmentDummy())
85 |
86 | // then
87 | XCTAssertNil(sectionLayout)
88 | }
89 |
90 | func test_given_section_without_layout_when_sectionLayout_then_return_nil() {
91 | let dataSource = CollectionViewLayoutAdapterDataSourceStub()
92 | dataSource.section = Section(id: UUID(), cells: [])
93 |
94 | let sut = sut()
95 | sut.dataSource = dataSource
96 |
97 | // when
98 | let sectionLayout = sut.sectionLayout(index: 0, environment: NSCollectionLayoutEnvironmentDummy())
99 |
100 | // then
101 | XCTAssertNil(sectionLayout)
102 | }
103 |
104 | func test_given_section_with_emptyCell_when_sectionLayout_then_return_nil() {
105 | let dataSource = CollectionViewLayoutAdapterDataSourceStub()
106 | dataSource.section = Section(id: UUID(), cells: []).withSectionLayout { _ in
107 | .init(
108 | group: .init(
109 | layoutSize: .init(
110 | widthDimension: .absolute(44.0),
111 | heightDimension: .absolute(44.0)
112 | )
113 | )
114 | )
115 | }
116 |
117 | let sut = sut()
118 | sut.dataSource = dataSource
119 |
120 | // when
121 | let sectionLayout = sut.sectionLayout(index: 0, environment: NSCollectionLayoutEnvironmentDummy())
122 |
123 | // then
124 | XCTAssertNil(sectionLayout)
125 | }
126 |
127 | func test_given_valid_section_when_sectionLayout_then_return_sectionLayout() {
128 | let dataSource = CollectionViewLayoutAdapterDataSourceStub()
129 | dataSource.sizeStorageStub = ComponentSizeStorageDummy()
130 | dataSource.section = Section(
131 | id: UUID(),
132 | cells: [Cell(id: UUID(), component: DummyComponent())]
133 | ).withSectionLayout { _ in
134 | .init(
135 | group: .vertical(
136 | layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(0), heightDimension: .absolute(0)),
137 | subitems: [.init(layoutSize: .init(widthDimension: .absolute(0), heightDimension: .absolute(0)))]
138 | )
139 | )
140 | }
141 |
142 | let sut = sut()
143 | sut.dataSource = dataSource
144 |
145 | // when
146 | let sectionLayout = sut.sectionLayout(index: 0, environment: NSCollectionLayoutEnvironmentDummy())
147 |
148 | // then
149 | XCTAssertNotNil(sectionLayout)
150 | }
151 | }
152 | #endif
153 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/List.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import Foundation
6 |
7 | #if canImport(UIKit)
8 |
9 | /// The `List` that representing a UICollectionView.
10 | public struct List: ListingViewEventHandler {
11 |
12 | /// A array of section that representing Section UI.
13 | public var sections: [Section]
14 |
15 | let eventStorage = ListingViewEventStorage()
16 |
17 | // MARK: - Initializer
18 |
19 | /// The initializer method that creates a List.
20 | ///
21 | /// - Parameters:
22 | /// - sections: An array of section to be displayed on the screen.
23 | public init(
24 | sections: [Section]
25 | ) {
26 | self.sections = sections
27 | }
28 |
29 | /// The initializer method that creates a List.
30 | ///
31 | /// - Parameters:
32 | /// - sections: The Builder that creates an array of section to be displayed on the screen.
33 | public init(
34 | @SectionsBuilder _ sections: () -> [Section]
35 | ) {
36 | self.sections = sections()
37 | }
38 | }
39 |
40 | // MARK: - Event Handler
41 |
42 | extension List {
43 |
44 | /// Register a callback handler that will be called when the user scrolls the content view.
45 | ///
46 | /// - Parameters:
47 | /// - handler: The callback handler for did scroll event
48 | public func didScroll(_ handler: @escaping (DidScrollEvent.EventContext) -> Void) -> Self {
49 | registerEvent(DidScrollEvent(handler: handler))
50 | }
51 |
52 | /// Register a callback handler that will be called when the user pull to refresh.
53 | ///
54 | /// - Parameters:
55 | /// - handler: The callback handler for did pull to refresh event
56 | public func onRefresh(_ handler: @escaping (PullToRefreshEvent.EventContext) -> Void) -> Self {
57 | registerEvent(PullToRefreshEvent(handler: handler))
58 | }
59 |
60 | /// Register a callback handler that will be called when the user scrolls near the end of the content view.
61 | ///
62 | /// - Parameters:
63 | /// - offset: The offset from the end of the content view that triggers the event. Default is two times the height of the content view.
64 | /// - handler: The callback handler for on reached end event.
65 | /// - Returns: An updated `List` with the registered event handler.
66 | public func onReachEnd(
67 | offsetFromEnd offset: ReachedEndEvent.OffsetFromEnd = .relativeToContainerSize(multiplier: 2.0),
68 | _ handler: @escaping (ReachedEndEvent.EventContext) -> Void
69 | ) -> Self {
70 | registerEvent(
71 | ReachedEndEvent(
72 | offset: offset,
73 | handler: handler
74 | )
75 | )
76 | }
77 |
78 | /// Register a callback handler that will be called when the scrollView is about to start scrolling the content.
79 | ///
80 | /// - Parameters:
81 | /// - handler: The callback handler for will begin dragging event
82 | public func willBeginDragging(_ handler: @escaping (WillBeginDraggingEvent.EventContext) -> Void) -> Self {
83 | registerEvent(WillBeginDraggingEvent(handler: handler))
84 | }
85 |
86 | /// Register a callback handler that will be called when the user finishes scrolling the content.
87 | ///
88 | /// - Parameters:
89 | /// - handler: The callback handler for will end dragging event
90 | public func willEndDragging(_ handler: @escaping (WillEndDraggingEvent.EventContext) -> Void) -> Self {
91 | registerEvent(WillEndDraggingEvent(handler: handler))
92 | }
93 |
94 | /// Register a callback handler that will be called when the user finished scrolling the content.
95 | ///
96 | /// - Parameters:
97 | /// - handler: The callback handler for did end dragging event
98 | public func didEndDragging(_ handler: @escaping (DidEndDraggingEvent.EventContext) -> Void) -> Self {
99 | registerEvent(DidEndDraggingEvent(handler: handler))
100 | }
101 |
102 | /// Register a callback handler that will be called when the scroll view scrolled to the top of the content.
103 | ///
104 | /// - Parameters:
105 | /// - handler: The callback handler for did scroll to top event
106 | public func didScrollToTop(_ handler: @escaping (DidScrollToTopEvent.EventContext) -> Void) -> Self {
107 | registerEvent(DidScrollToTopEvent(handler: handler))
108 | }
109 |
110 | /// Register a callback handler that will be called when the scroll view is starting to decelerate the scrolling movement.
111 | ///
112 | /// - Parameters:
113 | /// - handler: The callback handler for will begin decelerating event
114 | public func willBeginDecelerating(_ handler: @escaping (WillBeginDeceleratingEvent.EventContext) -> Void) -> Self {
115 | registerEvent(WillBeginDeceleratingEvent(handler: handler))
116 | }
117 |
118 | /// Register a callback handler that will be called when the scroll view ended decelerating the scrolling movement.
119 | ///
120 | /// - Parameters:
121 | /// - handler: The callback handler for did end decelerating event
122 | public func didEndDecelerating(_ handler: @escaping (DidEndDeceleratingEvent.EventContext) -> Void) -> Self {
123 | registerEvent(DidEndDeceleratingEvent(handler: handler))
124 | }
125 |
126 | /// Register a callback handler that will be called when the scroll view should scroll to the top of the content
127 | ///
128 | /// - Parameters:
129 | /// - handler: The callback handler for shouldScrollToTop event
130 | public func shouldScrollToTop(_ handler: @escaping (ShouldScrollToTopEvent.EventContext) -> Bool) -> Self {
131 | registerEvent(ShouldScrollToTopEvent(handler: handler))
132 | }
133 | }
134 | #endif
135 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Layout/VerticalGridLayout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This layout supports grid-style vertical scrolling.
9 | ///
10 | /// If the number of Cells to be displayed in a row is specified, This layout makes it easy to implement.
11 | /// - Note: when using a vertical grid layout, the layout mode of the component must be Flexible High.
12 | public struct VerticalGridLayout: CompositionalLayoutSectionFactory {
13 |
14 | private let numberOfItemsInRow: Int
15 | private let itemSpacing: CGFloat
16 | private let lineSpacing: CGFloat
17 | private var sectionInsets: NSDirectionalEdgeInsets?
18 | private var headerPinToVisibleBounds: Bool?
19 | private var footerPinToVisibleBounds: Bool?
20 | private var visibleItemsInvalidationHandler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
21 |
22 | /// Initializes a new vertical grid layout.
23 | /// - Parameters:
24 | /// - numberOfItemsInRow: The number of items in a row.
25 | /// - itemSpacing: The spacing between items in the layout.
26 | /// - lineSpacing: The spacing between lines in the layout.
27 | public init(
28 | numberOfItemsInRow: Int,
29 | itemSpacing: CGFloat,
30 | lineSpacing: CGFloat
31 | ) {
32 | self.numberOfItemsInRow = numberOfItemsInRow
33 | self.itemSpacing = itemSpacing
34 | self.lineSpacing = lineSpacing
35 | }
36 |
37 | /// Creates a layout for a section.
38 | public func makeSectionLayout() -> SectionLayout? {
39 | { context -> NSCollectionLayoutSection? in
40 | var verticalGroupHeight: CGFloat = 0
41 | let horizontalGroups = context.section.cells.chunks(ofCount: numberOfItemsInRow).map { chunkedCells in
42 | let horizontalGroupHeight = layoutCellItems(cells: Array(chunkedCells), sizeStorage: context.sizeStorage)
43 | .max { layout1, layout2 in
44 | layout1.layoutSize.heightDimension.dimension < layout2.layoutSize.heightDimension.dimension
45 | }?.layoutSize.heightDimension ?? .estimated(context.environment.container.contentSize.height)
46 |
47 | verticalGroupHeight += horizontalGroupHeight.dimension
48 |
49 | let layoutGroup = NSCollectionLayoutGroup.horizontal(
50 | layoutSize: .init(
51 | widthDimension: .fractionalWidth(1.0),
52 | heightDimension: horizontalGroupHeight
53 | ),
54 | subitem: NSCollectionLayoutItem(
55 | layoutSize: .init(
56 | widthDimension: .fractionalWidth(1.0 / CGFloat(numberOfItemsInRow)),
57 | heightDimension: horizontalGroupHeight
58 | )
59 | ),
60 | count: numberOfItemsInRow
61 | )
62 | layoutGroup.interItemSpacing = .fixed(itemSpacing)
63 |
64 | return layoutGroup
65 | }
66 |
67 | let group = NSCollectionLayoutGroup.vertical(
68 | layoutSize: .init(
69 | widthDimension: .fractionalWidth(1.0),
70 | heightDimension: .estimated(verticalGroupHeight)
71 | ),
72 | subitems: horizontalGroups
73 | )
74 | group.interItemSpacing = .fixed(lineSpacing)
75 |
76 | let section = NSCollectionLayoutSection(group: group)
77 | if let sectionInsets {
78 | section.contentInsets = sectionInsets
79 | }
80 |
81 | if let visibleItemsInvalidationHandler {
82 | section.visibleItemsInvalidationHandler = visibleItemsInvalidationHandler
83 | }
84 |
85 | let headerItem = layoutHeaderItem(section: context.section, sizeStorage: context.sizeStorage)
86 | if let headerPinToVisibleBounds {
87 | headerItem?.pinToVisibleBounds = headerPinToVisibleBounds
88 | }
89 |
90 | let footerItem = layoutFooterItem(section: context.section, sizeStorage: context.sizeStorage)
91 | if let footerPinToVisibleBounds {
92 | footerItem?.pinToVisibleBounds = footerPinToVisibleBounds
93 | }
94 |
95 | section.boundarySupplementaryItems = [
96 | headerItem,
97 | footerItem,
98 | ].compactMap { $0 }
99 |
100 | return section
101 | }
102 | }
103 |
104 | /// Sets the insets for the section.
105 | ///
106 | /// - Parameters:
107 | /// - insets: insets for section
108 | public func insets(_ insets: NSDirectionalEdgeInsets?) -> Self {
109 | var copy = self
110 | copy.sectionInsets = insets
111 | return copy
112 | }
113 |
114 | /// Sets whether the header should pin to visible bounds.
115 | ///
116 | /// - Parameters:
117 | /// - pinToVisibleBounds: A Boolean value that indicates whether a header is pinned to the top
118 | public func headerPinToVisibleBounds(_ pinToVisibleBounds: Bool?) -> Self {
119 | var copy = self
120 | copy.headerPinToVisibleBounds = pinToVisibleBounds
121 | return copy
122 | }
123 |
124 | /// Sets whether the footer should pin to visible bounds.
125 | ///
126 | /// - Parameters:
127 | /// - pinToVisibleBounds: A Boolean value that indicates whether a footer is pinned to the bottom
128 | public func footerPinToVisibleBounds(_ pinToVisibleBounds: Bool?) -> Self {
129 | var copy = self
130 | copy.footerPinToVisibleBounds = pinToVisibleBounds
131 | return copy
132 | }
133 |
134 | /// Sets the handler for invalidating visible items.
135 | ///
136 | /// - Parameters:
137 | /// - handler: visible items invalidation handler
138 | public func withVisibleItemsInvalidationHandler(
139 | _ handler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
140 | ) -> Self {
141 | var copy = self
142 | copy.visibleItemsInvalidationHandler = handler
143 | return copy
144 | }
145 | }
146 | #endif
147 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Component/AnyComponent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// A type-erased wrapper for any `Component`'s `ViewModel` that conforms to `Equatable`.
9 | public struct AnyViewModel: Equatable {
10 |
11 | private let base: any Equatable
12 |
13 | /// Creates a new instance of `AnyViewModel` by wrapping the given `Component`'s `ViewModel`.
14 | ///
15 | /// - Parameter base: The `Component` whose `ViewModel` to wrap.
16 | init(_ base: some Component) {
17 | self.base = base.viewModel
18 | }
19 |
20 | /// Returns a Boolean value indicating whether two `AnyViewModel` instances are equal.
21 | ///
22 | /// - Parameters:
23 | /// - lhs: The left-hand side value to compare.
24 | /// - rhs: The right-hand side value to compare.
25 | /// - Returns: `true` if `lhs` and `rhs` are equal; otherwise, `false`.
26 | public static func == (lhs: AnyViewModel, rhs: AnyViewModel) -> Bool {
27 | lhs.base.isEqual(rhs.base)
28 | }
29 | }
30 |
31 | /// A type-erased wrapper for any `Component` that conforms to `Component` and `Equatable`.
32 | public struct AnyComponent: Component, Equatable {
33 | private let box: any ComponentBox
34 |
35 | /// The any `Component` instance.
36 | public var base: any Component {
37 | box.base
38 | }
39 |
40 | /// The layout mode of the component's content.
41 | public var layoutMode: ContentLayoutMode {
42 | box.layoutMode
43 | }
44 |
45 | /// A reuse identifier for the component.
46 | public var reuseIdentifier: String {
47 | box.reuseIdentifier
48 | }
49 |
50 | /// The `ViewModel` of the component.
51 | public var viewModel: AnyViewModel {
52 | AnyViewModel(box.base)
53 | }
54 |
55 | /// Creates a new instance of `AnyComponent` by wrapping the given `Component`.
56 | ///
57 | /// - Parameter base: The `Component` to wrap.
58 | public init(_ base: some Component) {
59 | self.box = AnyComponentBox(base)
60 | }
61 |
62 | /// Creates the content object and configures its initial state.
63 | ///
64 | /// - Parameter coordinator: The coordinator to use for rendering the content.
65 | /// - Returns: The rendered content.
66 | public func renderContent(coordinator: Any) -> UIView {
67 | box.renderContent(coordinator: coordinator)
68 | }
69 |
70 | /// Updates the state of the specified content with new information
71 | ///
72 | /// - Parameters:
73 | /// - content: The content to render.
74 | /// - coordinator: The coordinator to use for rendering.
75 | public func render(in content: UIView, coordinator: Any) {
76 | box.render(in: content, coordinator: coordinator)
77 | }
78 |
79 | /// Lays out the specified content in the given container view.
80 | ///
81 | /// - Parameters:
82 | /// - content: The content to layout.
83 | /// - container: The view to layout the content in.
84 | public func layout(content: UIView, in container: UIView) {
85 | box.layout(content: content, in: container)
86 | }
87 |
88 | /// Attempts to downcast the underlying `Component` to the specified type.
89 | ///
90 | /// - Parameter _: The type to downcast to.
91 | /// - Returns: The underlying `Component` as the specified type, or `nil` if the downcast fails.
92 | public func `as`(_: T.Type) -> T? {
93 | box.base as? T
94 | }
95 |
96 | /// Creates the custom instance that you use to communicate changes from your view to other parts of your SwiftUI interface.
97 | ///
98 | /// If your view doesn’t interact with SwiftUI interface, providing a coordinator is unnecessary.
99 | /// - Returns: A new `Coordinator` instance.
100 | public func makeCoordinator() -> Any {
101 | box.makeCoordinator()
102 | }
103 |
104 | /// Returns a Boolean value indicating whether two `AnyComponent` instances are equal.
105 | ///
106 | /// - Parameters:
107 | /// - lhs: The left-hand side value to compare.
108 | /// - rhs: The right-hand side value to compare.
109 | /// - Returns: `true` if `lhs` and `rhs` are equal; otherwise, `false`.
110 | public static func == (lhs: AnyComponent, rhs: AnyComponent) -> Bool {
111 | lhs.viewModel == rhs.viewModel
112 | }
113 | }
114 |
115 | private protocol ComponentBox {
116 | associatedtype Base: Component
117 |
118 | var base: Base { get }
119 | var reuseIdentifier: String { get }
120 | var layoutMode: ContentLayoutMode { get }
121 | var viewModel: Base.ViewModel { get }
122 |
123 | func renderContent(coordinator: Any) -> UIView
124 | func render(in content: UIView, coordinator: Any)
125 | func layout(content: UIView, in container: UIView)
126 | func makeCoordinator() -> Any
127 | }
128 |
129 | private struct AnyComponentBox: ComponentBox {
130 |
131 | var reuseIdentifier: String {
132 | baseComponent.reuseIdentifier
133 | }
134 |
135 | var viewModel: Base.ViewModel {
136 | baseComponent.viewModel
137 | }
138 |
139 | var layoutMode: ContentLayoutMode {
140 | baseComponent.layoutMode
141 | }
142 |
143 | var baseComponent: Base
144 |
145 | var base: Base {
146 | baseComponent
147 | }
148 |
149 | init(_ base: Base) {
150 | self.baseComponent = base
151 | }
152 |
153 | func renderContent(coordinator: Any) -> UIView {
154 | baseComponent.renderContent(coordinator: coordinator as! Base.Coordinator)
155 | }
156 |
157 | func render(in content: UIView, coordinator: Any) {
158 | guard let content = content as? Base.Content,
159 | let coordinator = coordinator as? Base.Coordinator
160 | else {
161 | return
162 | }
163 |
164 | baseComponent.render(in: content, coordinator: coordinator)
165 | }
166 |
167 | func layout(content: UIView, in container: UIView) {
168 | guard let content = content as? Base.Content else { return }
169 |
170 | baseComponent.layout(content: content, in: container)
171 | }
172 |
173 | func makeCoordinator() -> Any {
174 | baseComponent.makeCoordinator()
175 | }
176 | }
177 | #endif
178 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Layout/DefaultCompositionalLayoutSectionFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// This object provides default layout factories
9 | ///
10 | /// You can use the default layouts to implement easily and quickly different styles layout
11 | /// If you need to create a custom layout, implement an object that conform `CompositionalLayoutSectionFactory`.
12 | public struct DefaultCompositionalLayoutSectionFactory: CompositionalLayoutSectionFactory {
13 |
14 | /// The LayoutSpec enum defines the type of layouts that can be provided.
15 | public enum LayoutSpec {
16 |
17 | /// A vertical layout with specified spacing.
18 | case vertical(spacing: CGFloat)
19 |
20 | /// A horizontal layout with specified spacing and scrolling behavior.
21 | case horizontal(
22 | spacing: CGFloat,
23 | scrollingBehavior: UICollectionLayoutSectionOrthogonalScrollingBehavior
24 | )
25 |
26 | /// A vertical grid layout with specified number of items in a row, item spacing, and line spacing.
27 | case verticalGrid(numberOfItemsInRow: Int, itemSpacing: CGFloat, lineSpacing: CGFloat)
28 | }
29 |
30 | private let spec: LayoutSpec
31 |
32 | /// Creates a vertical layout
33 | public static var vertical: Self = .init(spec: .vertical(spacing: 0))
34 |
35 | /// Creates a horizontal layout
36 | public static var horizontal: Self = .init(spec: .horizontal(spacing: 0, scrollingBehavior: .continuous))
37 |
38 | /// Creates a vertical layout with specified spacing.
39 | /// - Parameter spacing: The spacing between items in the layout. Default value is `0.0`.
40 | public static func vertical(spacing: CGFloat) -> Self {
41 | .init(spec: .vertical(spacing: spacing))
42 | }
43 |
44 | /// Creates a horizontal layout.
45 | /// - Parameters:
46 | /// - spacing: The spacing between items in the layout. Default value is 0.0.
47 | /// - scrollingBehavior: The behavior of the layout when scrolling. Default value is .continuous.
48 | public static func horizontal(
49 | spacing: CGFloat,
50 | scrollingBehavior: UICollectionLayoutSectionOrthogonalScrollingBehavior = .continuous
51 | ) -> Self {
52 | .init(spec: .horizontal(spacing: spacing, scrollingBehavior: scrollingBehavior))
53 | }
54 |
55 | /// Creates a vertical grid layout.
56 | /// - Parameters:
57 | /// - numberOfItemsInRow: The number of items in a row.
58 | /// - itemSpacing: The spacing between items in the layout.
59 | /// - lineSpacing: The spacing between lines in the layout.
60 | public static func verticalGrid(
61 | numberOfItemsInRow: Int,
62 | itemSpacing: CGFloat,
63 | lineSpacing: CGFloat
64 | ) -> Self {
65 | .init(
66 | spec: .verticalGrid(
67 | numberOfItemsInRow: numberOfItemsInRow,
68 | itemSpacing: itemSpacing,
69 | lineSpacing: lineSpacing
70 | )
71 | )
72 | }
73 |
74 | private var sectionContentInsets: NSDirectionalEdgeInsets?
75 | private var headerPinToVisibleBounds: Bool?
76 | private var footerPinToVisibleBounds: Bool?
77 | private var visibleItemsInvalidationHandler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
78 |
79 | /// Creates a section layout based on the specified layout spec.
80 | ///
81 | /// - Returns: layout closure for section
82 | public func makeSectionLayout() -> SectionLayout? {
83 | switch spec {
84 | case .vertical(let spacing):
85 | return VerticalLayout(spacing: spacing)
86 | .insets(sectionContentInsets)
87 | .headerPinToVisibleBounds(headerPinToVisibleBounds)
88 | .footerPinToVisibleBounds(footerPinToVisibleBounds)
89 | .withVisibleItemsInvalidationHandler(visibleItemsInvalidationHandler)
90 | .makeSectionLayout()
91 | case .horizontal(let spacing, let scrollingBehavior):
92 | return HorizontalLayout(spacing: spacing, scrollingBehavior: scrollingBehavior)
93 | .insets(sectionContentInsets)
94 | .headerPinToVisibleBounds(headerPinToVisibleBounds)
95 | .footerPinToVisibleBounds(footerPinToVisibleBounds)
96 | .withVisibleItemsInvalidationHandler(visibleItemsInvalidationHandler)
97 | .makeSectionLayout()
98 | case .verticalGrid(let numberOfItemsInRow, let itemSpacing, let lineSpacing):
99 | return VerticalGridLayout(
100 | numberOfItemsInRow: numberOfItemsInRow,
101 | itemSpacing: itemSpacing,
102 | lineSpacing: lineSpacing
103 | )
104 | .insets(sectionContentInsets)
105 | .headerPinToVisibleBounds(headerPinToVisibleBounds)
106 | .footerPinToVisibleBounds(footerPinToVisibleBounds)
107 | .withVisibleItemsInvalidationHandler(visibleItemsInvalidationHandler)
108 | .makeSectionLayout()
109 | }
110 | }
111 |
112 | /// Sets the insets for the section.
113 | ///
114 | /// - Parameters:
115 | /// - insets: insets for section
116 | public func withSectionContentInsets(_ insets: NSDirectionalEdgeInsets) -> Self {
117 | var copy = self
118 | copy.sectionContentInsets = insets
119 | return copy
120 | }
121 |
122 | /// Sets whether the header should pin to visible bounds.
123 | ///
124 | /// - Parameters:
125 | /// - pinToVisibleBounds: A Boolean value that indicates whether a header is pinned to the top
126 | public func withHeaderPinToVisibleBounds(_ pinToVisibleBounds: Bool) -> Self {
127 | var copy = self
128 | copy.headerPinToVisibleBounds = pinToVisibleBounds
129 | return copy
130 | }
131 |
132 | /// Sets whether the footer should pin to visible bounds.
133 | ///
134 | /// - Parameters:
135 | /// - pinToVisibleBounds: A Boolean value that indicates whether a footer is pinned to the bottom
136 | public func withFooterPinToVisibleBounds(_ pinToVisibleBounds: Bool) -> Self {
137 | var copy = self
138 | copy.footerPinToVisibleBounds = pinToVisibleBounds
139 | return copy
140 | }
141 |
142 | /// Sets the handler for invalidating visible items.
143 | ///
144 | /// - Parameters:
145 | /// - handler: visible items invalidation handler
146 | public func withVisibleItemsInvalidationHandler(
147 | _ visibleItemsInvalidationHandler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
148 | )
149 | -> Self {
150 | var copy = self
151 | copy.visibleItemsInvalidationHandler = visibleItemsInvalidationHandler
152 | return copy
153 | }
154 | }
155 | #endif
156 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitTests/ResultBuildersTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import XCTest
6 |
7 | @testable import KarrotListKit
8 |
9 | #if canImport(UIKit)
10 | final class ResultBuildersTests: XCTestCase {
11 |
12 | struct DummyComponent: Component {
13 | struct ViewModel: Equatable {}
14 | typealias Content = UIView
15 | typealias Coordinator = Void
16 | var layoutMode: ContentLayoutMode { fatalError() }
17 | var viewModel: ViewModel = .init()
18 | func renderContent(coordinator: Coordinator) -> UIView { fatalError() }
19 | func render(in content: UIView, coordinator: Coordinator) { fatalError() }
20 | }
21 | }
22 |
23 | // MARK: - CellBuilder
24 |
25 | extension ResultBuildersTests {
26 |
27 | func test_build_cells() {
28 | let section = Section(id: UUID()) {
29 | Cell(id: UUID(), component: DummyComponent())
30 | Cell(id: UUID(), component: DummyComponent())
31 | Cell(id: UUID(), component: DummyComponent())
32 | Cell(id: UUID(), component: DummyComponent())
33 | Cell(id: UUID(), component: DummyComponent())
34 | }
35 |
36 | XCTAssertEqual(section.cells.count, 5)
37 | }
38 |
39 | func test_build_cells_with_if_condition() {
40 | let condition = true
41 |
42 | let section = Section(id: UUID()) {
43 | if condition {
44 | Cell(id: UUID(), component: DummyComponent())
45 | Cell(id: UUID(), component: DummyComponent())
46 | Cell(id: UUID(), component: DummyComponent())
47 | Cell(id: UUID(), component: DummyComponent())
48 | Cell(id: UUID(), component: DummyComponent())
49 | }
50 | }
51 |
52 | XCTAssertEqual(section.cells.count, 5)
53 | }
54 |
55 | func test_build_cells_with_else_if_condition() {
56 | let condition = true
57 |
58 | let section = Section(id: UUID()) {
59 | if !condition {
60 | Cell(id: UUID(), component: DummyComponent())
61 | } else if condition {
62 | Cell(id: UUID(), component: DummyComponent())
63 | Cell(id: UUID(), component: DummyComponent())
64 | Cell(id: UUID(), component: DummyComponent())
65 | }
66 | }
67 |
68 | XCTAssertEqual(section.cells.count, 3)
69 | }
70 |
71 | func test_build_cells_with_else_condition() {
72 | let condition = true
73 |
74 | let section = Section(id: UUID()) {
75 | if !condition {
76 | Cell(id: UUID(), component: DummyComponent())
77 | } else {
78 | Cell(id: UUID(), component: DummyComponent())
79 | Cell(id: UUID(), component: DummyComponent())
80 | Cell(id: UUID(), component: DummyComponent())
81 | }
82 | }
83 |
84 | XCTAssertEqual(section.cells.count, 3)
85 | }
86 |
87 | func test_build_cells_with_loop() {
88 | let section = Section(id: UUID()) {
89 | for _ in 0 ..< 5 {
90 | Cell(id: UUID(), component: DummyComponent())
91 | }
92 | }
93 |
94 | XCTAssertEqual(section.cells.count, 5)
95 | }
96 |
97 | func test_build_cells_with_map() {
98 | let section = Section(id: UUID()) {
99 | (0 ..< 5).map { _ in
100 | Cell(id: UUID(), component: DummyComponent())
101 | }
102 | }
103 |
104 | XCTAssertEqual(section.cells.count, 5)
105 | }
106 |
107 | func test_build_cells_with_combination() {
108 | let section = Section(id: UUID()) {
109 | Cell(id: UUID(), component: DummyComponent())
110 | if true {
111 | Cell(id: UUID(), component: DummyComponent())
112 | (0 ..< 5).map { _ in
113 | Cell(id: UUID(), component: DummyComponent())
114 | }
115 | Cell(id: UUID(), component: DummyComponent())
116 | }
117 | for i in 0 ..< 10 {
118 | if i % 2 == 0 {
119 | Cell(id: UUID(), component: DummyComponent())
120 | } else {
121 | Cell(id: UUID(), component: DummyComponent())
122 | Cell(id: UUID(), component: DummyComponent())
123 | }
124 | }
125 | }
126 |
127 | XCTAssertEqual(section.cells.count, 23)
128 | }
129 | }
130 |
131 | // MARK: - SectionBuilder
132 |
133 | extension ResultBuildersTests {
134 |
135 | func test_build_sections() {
136 | let list = List {
137 | Section(id: UUID(), cells: [])
138 | Section(id: UUID(), cells: [])
139 | Section(id: UUID(), cells: [])
140 | Section(id: UUID(), cells: [])
141 | Section(id: UUID(), cells: [])
142 | }
143 |
144 | XCTAssertEqual(list.sections.count, 5)
145 | }
146 |
147 | func test_build_sections_with_if_condition() {
148 | let condition = true
149 |
150 | let list = List {
151 | if condition {
152 | Section(id: UUID(), cells: [])
153 | Section(id: UUID(), cells: [])
154 | Section(id: UUID(), cells: [])
155 | Section(id: UUID(), cells: [])
156 | Section(id: UUID(), cells: [])
157 | }
158 | }
159 |
160 | XCTAssertEqual(list.sections.count, 5)
161 | }
162 |
163 | func test_build_sections_with_else_if_condition() {
164 | let condition = true
165 |
166 | let list = List {
167 | if !condition {
168 | Section(id: UUID(), cells: [])
169 | } else if condition {
170 | Section(id: UUID(), cells: [])
171 | Section(id: UUID(), cells: [])
172 | Section(id: UUID(), cells: [])
173 | }
174 | }
175 |
176 | XCTAssertEqual(list.sections.count, 3)
177 | }
178 |
179 | func test_build_sections_with_else_condition() {
180 | let condition = true
181 |
182 | let list = List {
183 | if !condition {
184 | Section(id: UUID(), cells: [])
185 | } else {
186 | Section(id: UUID(), cells: [])
187 | Section(id: UUID(), cells: [])
188 | Section(id: UUID(), cells: [])
189 | }
190 | }
191 |
192 | XCTAssertEqual(list.sections.count, 3)
193 | }
194 |
195 | func test_build_sections_with_loop() {
196 | let list = List {
197 | for _ in 0 ..< 5 {
198 | Section(id: UUID(), cells: [])
199 | }
200 | }
201 |
202 | XCTAssertEqual(list.sections.count, 5)
203 | }
204 |
205 | func test_build_sections_with_map() {
206 | let list = List {
207 | (0 ..< 5).map { _ in
208 | Section(id: UUID(), cells: [])
209 | }
210 | }
211 |
212 | XCTAssertEqual(list.sections.count, 5)
213 | }
214 |
215 | func test_build_sections_with_combination() {
216 | let list = List {
217 | Section(id: UUID(), cells: [])
218 | if true {
219 | Section(id: UUID(), cells: [])
220 | (0 ..< 5).map { _ in
221 | Section(id: UUID(), cells: [])
222 | }
223 | Section(id: UUID(), cells: [])
224 | }
225 | for i in 0 ..< 10 {
226 | if i % 2 == 0 {
227 | Section(id: UUID(), cells: [])
228 | } else {
229 | Section(id: UUID(), cells: [])
230 | Section(id: UUID(), cells: [])
231 | }
232 | }
233 | }
234 |
235 | XCTAssertEqual(list.sections.count, 23)
236 | }
237 | }
238 | #endif
239 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Layout/CompositionalLayoutSectionFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | #if canImport(UIKit)
6 | import UIKit
7 |
8 | /// A protocol that creates and returns layout for section
9 | ///
10 | /// Any type that conforms to this protocol is responsible for creating `NSCollectionLayoutSection`.
11 | /// It provides a default implementation for calculating sizes for `Cell` and `ReusableViews`.
12 | public protocol CompositionalLayoutSectionFactory {
13 |
14 | /// A type that represents the context for layout, including the section, index, layout environment, and size storage.
15 | /// `ComponentSizeStorage` caches the actual size information of the component.
16 | typealias LayoutContext = (
17 | section: Section,
18 | index: Int,
19 | environment: NSCollectionLayoutEnvironment,
20 | sizeStorage: ComponentSizeStorage
21 | )
22 |
23 | /// A type that represents a layout closure for section.
24 | typealias SectionLayout = (_ context: LayoutContext) -> NSCollectionLayoutSection?
25 |
26 | /// Creates a layout for a section.
27 | ///
28 | /// - Returns: layout closure for section
29 | func makeSectionLayout() -> SectionLayout?
30 |
31 | /// Make layout items for cells.
32 | ///
33 | /// - Parameters:
34 | /// - cells: cells for size calculation
35 | /// - sizeStorage: The storage that cached size of the cell component.
36 | /// - Returns: layout items for cells
37 | func layoutCellItems(cells: [Cell], sizeStorage: ComponentSizeStorage) -> [NSCollectionLayoutItem]
38 |
39 | /// Make layout item for a section header.
40 | ///
41 | /// - Parameters:
42 | /// - section: The section that contains header
43 | /// - sizeStorage: The storage that cached size of the header component.
44 | /// - Returns: layout item for header
45 | func layoutHeaderItem(section: Section, sizeStorage: ComponentSizeStorage)
46 | -> NSCollectionLayoutBoundarySupplementaryItem?
47 |
48 | /// Make layout item for a section footer.
49 | ///
50 | /// - Parameters:
51 | /// - section: The section that contains footer
52 | /// - sizeStorage: The storage that cached size of the footer component.
53 | /// - Returns: layout item for footer
54 | func layoutFooterItem(section: Section, sizeStorage: ComponentSizeStorage)
55 | -> NSCollectionLayoutBoundarySupplementaryItem?
56 | }
57 |
58 | extension CompositionalLayoutSectionFactory {
59 |
60 | /// Default Implementation factory method for cells
61 | ///
62 | /// - Parameters:
63 | /// - cells: cells for size calculation
64 | /// - sizeStorage: The storage that cached size of the cell component.
65 | /// - Returns: layout items for cells
66 | public func layoutCellItems(cells: [Cell], sizeStorage: ComponentSizeStorage) -> [NSCollectionLayoutItem] {
67 | cells.map {
68 | if let sizeContext = sizeStorage.cellSize(for: $0.id),
69 | sizeContext.viewModel == $0.component.viewModel {
70 | return NSCollectionLayoutItem(layoutSize: makeLayoutSize(
71 | mode: $0.component.layoutMode,
72 | size: sizeContext.size
73 | ))
74 | } else {
75 | return NSCollectionLayoutItem(
76 | layoutSize: makeLayoutSize(mode: $0.component.layoutMode)
77 | )
78 | }
79 | }
80 | }
81 |
82 | /// Default Implementation factory method for header
83 | ///
84 | /// - Parameters:
85 | /// - section: The section that contains header
86 | /// - sizeStorage: The storage that cached size of the header component.
87 | /// - Returns: layout item for header
88 | public func layoutHeaderItem(
89 | section: Section,
90 | sizeStorage: ComponentSizeStorage
91 | ) -> NSCollectionLayoutBoundarySupplementaryItem? {
92 | guard let header = section.header else {
93 | return nil
94 | }
95 |
96 | if let sizeContext = sizeStorage.headerSize(for: section.id),
97 | sizeContext.viewModel == header.component.viewModel {
98 | return NSCollectionLayoutBoundarySupplementaryItem(
99 | layoutSize: makeLayoutSize(
100 | mode: header.component.layoutMode,
101 | size: sizeContext.size
102 | ),
103 | elementKind: header.kind,
104 | alignment: header.alignment
105 | )
106 | } else {
107 | return NSCollectionLayoutBoundarySupplementaryItem(
108 | layoutSize: makeLayoutSize(mode: header.component.layoutMode),
109 | elementKind: header.kind,
110 | alignment: header.alignment
111 | )
112 | }
113 | }
114 |
115 | /// Default Implementation factory method for footer
116 | ///
117 | /// - Parameters:
118 | /// - section: The section that contains footer
119 | /// - sizeStorage: The storage that cached size of the footer component.
120 | /// - Returns: layout item for footer
121 | public func layoutFooterItem(
122 | section: Section,
123 | sizeStorage: ComponentSizeStorage
124 | ) -> NSCollectionLayoutBoundarySupplementaryItem? {
125 | guard let footer = section.footer else {
126 | return nil
127 | }
128 |
129 | if let sizeContext = sizeStorage.footerSize(for: section.id),
130 | sizeContext.viewModel == footer.component.viewModel {
131 | return NSCollectionLayoutBoundarySupplementaryItem(
132 | layoutSize: makeLayoutSize(
133 | mode: footer.component.layoutMode,
134 | size: sizeContext.size
135 | ),
136 | elementKind: footer.kind,
137 | alignment: footer.alignment
138 | )
139 | } else {
140 | return NSCollectionLayoutBoundarySupplementaryItem(
141 | layoutSize: makeLayoutSize(mode: footer.component.layoutMode),
142 | elementKind: footer.kind,
143 | alignment: footer.alignment
144 | )
145 | }
146 | }
147 |
148 | private func makeLayoutSize(mode: ContentLayoutMode) -> NSCollectionLayoutSize {
149 | switch mode {
150 | case .fitContainer:
151 | return .init(
152 | widthDimension: .fractionalWidth(1.0),
153 | heightDimension: .fractionalHeight(1.0)
154 | )
155 | case .flexibleWidth(let estimatedWidth):
156 | return .init(
157 | widthDimension: .estimated(estimatedWidth),
158 | heightDimension: .fractionalHeight(1.0)
159 | )
160 | case .flexibleHeight(let estimatedHeight):
161 | return .init(
162 | widthDimension: .fractionalWidth(1.0),
163 | heightDimension: .estimated(estimatedHeight)
164 | )
165 | case .fitContent(let estimatedSize):
166 | return .init(
167 | widthDimension: .estimated(estimatedSize.width),
168 | heightDimension: .estimated(estimatedSize.height)
169 | )
170 | }
171 | }
172 |
173 | private func makeLayoutSize(mode: ContentLayoutMode, size: CGSize) -> NSCollectionLayoutSize {
174 | switch mode {
175 | case .fitContainer:
176 | return .init(
177 | widthDimension: .fractionalWidth(1.0),
178 | heightDimension: .fractionalHeight(1.0)
179 | )
180 | case .flexibleWidth:
181 | return .init(
182 | widthDimension: .estimated(size.width),
183 | heightDimension: .fractionalHeight(1.0)
184 | )
185 | case .flexibleHeight:
186 | return .init(
187 | widthDimension: .fractionalWidth(1.0),
188 | heightDimension: .estimated(size.height)
189 | )
190 | case .fitContent:
191 | return .init(
192 | widthDimension: .estimated(size.width),
193 | heightDimension: .estimated(size.height)
194 | )
195 | }
196 | }
197 | }
198 | #endif
199 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Section.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2024 Danggeun Market Inc.
3 | //
4 |
5 | import DifferenceKit
6 |
7 | #if canImport(UIKit)
8 | import UIKit
9 |
10 | /// The `Section` that representing a UICollectionView Section.
11 | ///
12 | /// The Section has a data structure similar to a Section UI hierarchy for example, an array of Cells, header, footer, etc.
13 | /// So we just make the section data for represent Section UI
14 | ///
15 | /// - Note: The layout depends on NSCollectionLayoutSection and
16 | /// you must set the layout through the withSectionLayout modifier.
17 | public struct Section: Identifiable, ListingViewEventHandler {
18 |
19 | /// The identifier for `Section`
20 | public let id: AnyHashable
21 |
22 | /// The header that representing header view
23 | public var header: SupplementaryView?
24 |
25 | /// A array of cell that representing UICollectionViewCell.
26 | public var cells: [Cell]
27 |
28 | /// The footer that representing footer view
29 | public var footer: SupplementaryView?
30 |
31 | private var sectionLayout: CompositionalLayoutSectionFactory.SectionLayout?
32 |
33 | let eventStorage: ListingViewEventStorage
34 |
35 | // MARK: - Initializer
36 |
37 | /// The initializer method that creates a Section.
38 | ///
39 | /// - Parameters:
40 | /// - id: The identifier that identifies the Section.
41 | /// - cells: An array of cell to be displayed on the screen.
42 | public init(
43 | id: some Hashable,
44 | cells: [Cell]
45 | ) {
46 | self.id = id
47 | self.cells = cells
48 | self.eventStorage = ListingViewEventStorage()
49 | }
50 |
51 | /// The initializer method that creates a Section.
52 | ///
53 | /// - Parameters:
54 | /// - id: The identifier that identifies the Section.
55 | /// - cells: The Builder that creates an array of cell to be displayed on the screen.
56 | public init(
57 | id: some Hashable,
58 | @CellsBuilder _ cells: () -> [Cell]
59 | ) {
60 | self.id = id
61 | self.cells = cells()
62 | self.eventStorage = ListingViewEventStorage()
63 | }
64 |
65 | /// The modifier that specifies the layout for the Section.
66 | ///
67 | /// - Parameters:
68 | /// - sectionLayout: A closure that custom section layout provider.
69 | public func withSectionLayout(_ sectionLayout: CompositionalLayoutSectionFactory.SectionLayout?) -> Self {
70 | var copy = self
71 | copy.sectionLayout = sectionLayout
72 | return copy
73 | }
74 |
75 | /// The modifier that sets the layout for the Section.
76 | ///
77 | /// - Parameters:
78 | /// - layoutMaker: A factory object that creates an NSCollectionLayoutSection.
79 | public func withSectionLayout(_ layoutMaker: CompositionalLayoutSectionFactory) -> Self {
80 | var copy = self
81 | copy.sectionLayout = layoutMaker.makeSectionLayout()
82 | return copy
83 | }
84 |
85 | /// The modifier that sets the layout for the Section.
86 | ///
87 | /// - Parameters:
88 | /// - defaultLayoutMaker: The basic layout factory provided by the framework.
89 | public func withSectionLayout(_ defaultLayoutMaker: DefaultCompositionalLayoutSectionFactory) -> Self {
90 | var copy = self
91 | copy.sectionLayout = defaultLayoutMaker.makeSectionLayout()
92 | return copy
93 | }
94 |
95 | /// The modifier that sets the Header for the Section.
96 | ///
97 | /// - Parameters:
98 | /// - headerComponent: The component that the header represents.
99 | /// - alignment: The alignment of the component.
100 | public func withHeader(
101 | _ headerComponent: some Component,
102 | alignment: NSRectAlignment = .top
103 | ) -> Self {
104 | var copy = self
105 | copy.header = .init(
106 | kind: UICollectionView.elementKindSectionHeader,
107 | component: headerComponent,
108 | alignment: alignment
109 | )
110 | return copy
111 | }
112 |
113 | /// The modifier that sets the Footer for the Section.
114 | ///
115 | /// - Parameters:
116 | /// - footerComponent: The component that the footer represents.
117 | /// - alignment: The alignment of the component.
118 | public func withFooter(
119 | _ footerComponent: some Component,
120 | alignment: NSRectAlignment = .bottom
121 | ) -> Self {
122 | var copy = self
123 | copy.footer = .init(
124 | kind: UICollectionView.elementKindSectionFooter,
125 | component: footerComponent,
126 | alignment: alignment
127 | )
128 | return copy
129 | }
130 |
131 | func layout(
132 | index: Int,
133 | environment: NSCollectionLayoutEnvironment,
134 | sizeStorage: ComponentSizeStorage
135 | ) -> NSCollectionLayoutSection? {
136 | if sectionLayout == nil {
137 | assertionFailure("Please specify a valid section layout")
138 | }
139 |
140 | return sectionLayout?((self, index, environment, sizeStorage))
141 | }
142 | }
143 |
144 | // MARK: - Event Handler
145 |
146 | extension Section {
147 | /// Register a callback handler that will be called when the header is displayed on the screen.
148 | ///
149 | /// - Parameters:
150 | /// - handler: The callback handler when header is displayed on the screen.
151 | @discardableResult
152 | public func willDisplayHeader(_ handler: @escaping (WillDisplayEvent.EventContext) -> Void) -> Self {
153 | var copy = self
154 | if header == nil {
155 | assertionFailure("Please declare the header first using [withHeader]")
156 | }
157 | copy.header = header?.willDisplay(handler)
158 | return copy
159 | }
160 |
161 | /// Register a callback handler that will be called when the footer is displayed on the screen.
162 | ///
163 | /// - Parameters:
164 | /// - handler: The callback handler when footer is displayed on the screen.
165 | @discardableResult
166 | public func willDisplayFooter(_ handler: @escaping (WillDisplayEvent.EventContext) -> Void) -> Self {
167 | var copy = self
168 | if footer == nil {
169 | assertionFailure("Please declare the footer first using [withFooter]")
170 | }
171 | copy.footer = footer?.willDisplay(handler)
172 | return copy
173 | }
174 |
175 | /// Registers a callback handler that will be called when the header is removed from the screen.
176 | ///
177 | /// - Parameters:
178 | /// - handler: The callback handler when the header is removed from the screen.
179 | public func didEndDisplayHeader(_ handler: @escaping (DidEndDisplayingEvent.EventContext) -> Void) -> Self {
180 | var copy = self
181 | if header == nil {
182 | assertionFailure("Please declare the header first using [withHeader]")
183 | }
184 | copy.header = header?.didEndDisplaying(handler)
185 | return copy
186 | }
187 |
188 | /// Registers a callback handler that will be called when the footer is removed from the screen.
189 | ///
190 | /// - Parameters:
191 | /// - handler: The callback handler when the footer is removed from the screen.
192 | public func didEndDisplayFooter(_ handler: @escaping (DidEndDisplayingEvent.EventContext) -> Void) -> Self {
193 | var copy = self
194 | if footer == nil {
195 | assertionFailure("Please declare the footer first using [withFooter]")
196 | }
197 | copy.footer = footer?.didEndDisplaying(handler)
198 | return copy
199 | }
200 | }
201 |
202 | // MARK: - Hashable
203 |
204 | extension Section: Hashable {
205 | public func hash(into hasher: inout Hasher) {
206 | hasher.combine(id)
207 | }
208 |
209 | public static func == (lhs: Section, rhs: Section) -> Bool {
210 | lhs.id == rhs.id && lhs.header == rhs.header && lhs.footer == rhs.footer
211 | }
212 | }
213 |
214 | // MARK: - DifferentiableSection
215 |
216 | extension Section: DifferentiableSection {
217 | public var differenceIdentifier: AnyHashable {
218 | id
219 | }
220 |
221 | public var elements: [Cell] {
222 | cells
223 | }
224 |
225 | public init(source: Section, elements cells: some Swift.Collection| ) {
226 | self = source
227 | self.cells = Array(cells)
228 | }
229 |
230 | public func isContentEqual(to source: Self) -> Bool {
231 | self == source
232 | }
233 | }
234 | #endif
235 |
--------------------------------------------------------------------------------
/Tests/KarrotListKitMacrosTests/AddComponentModifierMacroTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Created by Daangn Jaxtyn on 7/18/25.
3 | // Copyright © 2025 Danggeun Market Inc. All rights reserved.
4 | //
5 |
6 | import SwiftSyntaxMacros
7 | import SwiftSyntaxMacrosTestSupport
8 | import XCTest
9 |
10 | #if canImport(KarrotListKitMacros)
11 | import KarrotListKitMacros
12 | #endif
13 |
14 | final class AddComponentModifierMacroTests: XCTestCase {
15 |
16 | #if canImport(KarrotListKitMacros)
17 | let testMacros: [String: Macro.Type] = [
18 | "AddComponentModifier": AddComponentModifierMacro.self,
19 | ]
20 | #endif
21 |
22 | func testExpansionWithPublicStruct() throws {
23 | #if canImport(KarrotListKitMacros) && canImport(SwiftSyntax600)
24 | assertMacroExpansion(
25 | """
26 | public struct VerticalLayoutItemComponent: Component {
27 |
28 | @AddComponentModifier
29 | public var onTapButtonHandler: (() -> Void)?
30 |
31 | @AddComponentModifier
32 | private var onTapButtonWithValueHandler: ((Int) -> Void)?
33 |
34 | @AddComponentModifier
35 | internal var onTapButtonWithValuesHandler: ((Int, String) -> Void)?
36 |
37 | @AddComponentModifier
38 | var onTapButtonWithNamedValuesHandler: ((_ intValue: Int, _ stringValue: String) -> Void)?
39 | }
40 | """,
41 | expandedSource: """
42 | public struct VerticalLayoutItemComponent: Component {
43 | public var onTapButtonHandler: (() -> Void)?
44 |
45 | public func onTapButton(_ handler: @escaping () -> Void) -> Self {
46 | var copy = self
47 | copy.onTapButtonHandler = handler
48 | return copy
49 | }
50 | private var onTapButtonWithValueHandler: ((Int) -> Void)?
51 |
52 | public func onTapButtonWithValue(_ handler: @escaping (Int) -> Void) -> Self {
53 | var copy = self
54 | copy.onTapButtonWithValueHandler = handler
55 | return copy
56 | }
57 | internal var onTapButtonWithValuesHandler: ((Int, String) -> Void)?
58 |
59 | public func onTapButtonWithValues(_ handler: @escaping (Int, String) -> Void) -> Self {
60 | var copy = self
61 | copy.onTapButtonWithValuesHandler = handler
62 | return copy
63 | }
64 | var onTapButtonWithNamedValuesHandler: ((_ intValue: Int, _ stringValue: String) -> Void)?
65 |
66 | public func onTapButtonWithNamedValues(_ handler: @escaping (_ intValue: Int, _ stringValue: String) -> Void) -> Self {
67 | var copy = self
68 | copy.onTapButtonWithNamedValuesHandler = handler
69 | return copy
70 | }
71 | }
72 | """,
73 | macros: testMacros,
74 | indentationWidth: .spaces(2)
75 | )
76 | #else
77 | throw XCTSkip("macros are only supported when running tests for the host platform")
78 | #endif
79 | }
80 |
81 | func testExpansionWithInternalStruct() throws {
82 | #if canImport(KarrotListKitMacros) && canImport(SwiftSyntax600)
83 | assertMacroExpansion(
84 | """
85 | struct InternalComponent: Component {
86 | @AddComponentModifier
87 | var onTapHandler: (() -> Void)?
88 | }
89 | """,
90 | expandedSource: """
91 | struct InternalComponent: Component {
92 | var onTapHandler: (() -> Void)?
93 |
94 | func onTap(_ handler: @escaping () -> Void) -> Self {
95 | var copy = self
96 | copy.onTapHandler = handler
97 | return copy
98 | }
99 | }
100 | """,
101 | macros: testMacros,
102 | indentationWidth: .spaces(2)
103 | )
104 | #else
105 | throw XCTSkip("macros are only supported when running tests for the host platform")
106 | #endif
107 | }
108 |
109 | func testExpansionWithPrivateStruct() throws {
110 | #if canImport(KarrotListKitMacros) && canImport(SwiftSyntax600)
111 | assertMacroExpansion(
112 | """
113 | private struct PrivateComponent: Component {
114 | @AddComponentModifier
115 | var onTapHandler: (() -> Void)?
116 | }
117 | """,
118 | expandedSource: """
119 | private struct PrivateComponent: Component {
120 | var onTapHandler: (() -> Void)?
121 |
122 | private func onTap(_ handler: @escaping () -> Void) -> Self {
123 | var copy = self
124 | copy.onTapHandler = handler
125 | return copy
126 | }
127 | }
128 | """,
129 | macros: testMacros,
130 | indentationWidth: .spaces(2)
131 | )
132 | #else
133 | throw XCTSkip("macros are only supported when running tests for the host platform")
134 | #endif
135 | }
136 |
137 | func testNonStructError() throws {
138 | #if canImport(KarrotListKitMacros) && canImport(SwiftSyntax600)
139 | assertMacroExpansion(
140 | """
141 | enum PrivateComponent: Component {
142 | @AddComponentModifier
143 | var onTapHandler: (() -> Void)?
144 | }
145 | """,
146 | expandedSource: """
147 | enum PrivateComponent: Component {
148 | var onTapHandler: (() -> Void)?
149 | }
150 | """,
151 | diagnostics: [
152 | DiagnosticSpec(
153 | message: "KarrotListKitMacroError(message: \"@AddComponentModifier can only be used on properties inside structs\")",
154 | line: 2,
155 | column: 3
156 | ),
157 | ],
158 | macros: testMacros,
159 | indentationWidth: .spaces(2)
160 | )
161 | #else
162 | throw XCTSkip("macros are only supported when running tests for the host platform")
163 | #endif
164 | }
165 |
166 | func testPropertyError() throws {
167 | #if canImport(KarrotListKitMacros) && canImport(SwiftSyntax600)
168 | assertMacroExpansion(
169 | """
170 | struct PrivateComponent: Component {
171 | @AddComponentModifier
172 | func onTapHandler() {}
173 |
174 | @AddComponentModifier
175 | let onTap2Handler: (() -> Void)? = nil
176 |
177 | @AddComponentModifier
178 | var onTap3Handler: Int = 0
179 |
180 | @AddComponentModifier
181 | var onTap4: (() -> Void)?
182 | }
183 | """,
184 | expandedSource: """
185 | struct PrivateComponent: Component {
186 | func onTapHandler() {}
187 | let onTap2Handler: (() -> Void)? = nil
188 | var onTap3Handler: Int = 0
189 | var onTap4: (() -> Void)?
190 |
191 | func onTap4(_ handler: @escaping () -> Void) -> Self {
192 | var copy = self
193 | copy.onTap4 = handler
194 | return copy
195 | }
196 | }
197 | """,
198 | diagnostics: [
199 | DiagnosticSpec(
200 | message: "KarrotListKitMacroError(message: \"@AddComponentModifier can only be applied to variable property\")",
201 | line: 2,
202 | column: 3
203 | ),
204 | DiagnosticSpec(
205 | message: "KarrotListKitMacroError(message: \"@AddComponentModifier can only be applied to variable property\")",
206 | line: 5,
207 | column: 3
208 | ),
209 | DiagnosticSpec(
210 | message: "KarrotListKitMacroError(message: \"@AddComponentModifier can only be applied to optional closure properties\")",
211 | line: 8,
212 | column: 3
213 | ),
214 | ],
215 | macros: testMacros,
216 | indentationWidth: .spaces(2)
217 | )
218 | #else
219 | throw XCTSkip("macros are only supported when running tests for the host platform")
220 | #endif
221 | }
222 |
223 | func testSwiftSyntaxVersionError() throws {
224 | #if canImport(KarrotListKitMacros) && !canImport(SwiftSyntax600)
225 | assertMacroExpansion(
226 | """
227 | struct PrivateComponent: Component {
228 | @AddComponentModifier
229 | var onTapHandler: (() -> Void)?
230 | }
231 | """,
232 | expandedSource: """
233 | enum PrivateComponent: Component {
234 | var onTapHandler: (() -> Void)?
235 | }
236 | """,
237 | diagnostics: [
238 | DiagnosticSpec(
239 | message: "KarrotListKitMacroError(message: \"@AddComponentModifier macro requires SwiftSyntax 600.0.0 or later.\")",
240 | line: 2,
241 | column: 3
242 | ),
243 | ],
244 | macros: testMacros,
245 | indentationWidth: .spaces(2)
246 | )
247 | #else
248 | throw XCTSkip("macros are only supported when running tests for the host platform")
249 | #endif
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # KarrotListKit
2 | [](https://swiftpackageindex.com/daangn/KarrotListKit)
3 | [](https://swiftpackageindex.com/daangn/KarrotListKit)
4 |
5 | Welcome to our Karrot Listing Framework. This powerful tool is developed with UIKit, but it is designed to be used like a declarative UI API, providing a seamless transition to SwiftUI and reducing migration costs.
6 |
7 | Our framework is built with an optimized diffing algorithm, thanks to its dependency on [DifferenceKit](https://github.com/ra1028/DifferenceKit). This ensures high performance and swift rendering of your lists, allowing your application to run smoothly, even when handling large changes of data.
8 |
9 | The API is designed to be simple and intuitive, allowing for rapid development without sacrificing quality or functionality. This means you can spend less time wrestling with complex code and more time creating the perfect user experience.
10 |
11 |
12 |
13 | ## Installation
14 |
15 | You can use The [Swift Package Manager](https://swift.org/package-manager/) to install this framework by following these steps:
16 |
17 | 1. Open up Xcode, and navigate to `Project` -> `Package dependencies` -> `Add Package Dependency (+)`.
18 | 1. In the search bar, enter the URL of this repository: `https://github.com/daangn/KarrotListKit`.
19 | 1. Specify the version you want to use. You can choose to use the latest version or a specific version.
20 | 1. Click `Next` and `Finish` to complete the installation. After the package is successfully added to your project, import the framework into the files where you want to use it:
21 |
22 | ```swift
23 | import KarrotListKit
24 | ```
25 |
26 | Now you're ready to start using the KarrotListKit Framework
27 |
28 |
29 |
30 |
31 | ## Getting Started
32 | See the [KarrotListKit DocC documentation](https://swiftpackageindex.com/daangn/KarrotListKit/main/documentation/karrotlistkit) hosted on the [Swift Package Index](https://swiftpackageindex.com/).
33 |
34 | ### CollectionViewAdapter
35 |
36 | The `CollectionViewAdapter` object serves as an adapter between the `UIColletionView` logic and the KarrotListKit logic, encapsulating the core implementation logic of the framework
37 |
38 | ```swift
39 | private let configuration = CollectionViewAdapterConfiguration()
40 | private let layoutAdapter = CollectionViewLayoutAdapter()
41 |
42 | private lazy var collectionViewAdapter = CollectionViewAdapter(
43 | configuration: configuration,
44 | collectionView: collectionView,
45 | layoutAdapter: layoutAdapter
46 | )
47 |
48 | private lazy var collectionView = UICollectionView(
49 | frame: .zero,
50 | collectionViewLayout: UICollectionViewCompositionalLayout(
51 | sectionProvider: layoutAdapter.sectionLayout
52 | )
53 | )
54 | ```
55 |
56 |
57 |
58 | ### Component
59 |
60 | The Component is the smallest unit within the framework.
61 | It allows for the declarative representation of data and actions to be displayed on the screen. We no longer need to depend on `UICollectionViewCell` and `UICollectionReusableView`, can write component-based.
62 | The component has an interface very similar to `UIViewRepresentable`. This similarity allows us to reduce the cost of migrating to `SwiftUI` in the future.
63 |
64 | ```swift
65 | struct ButtonComponent: Component {
66 |
67 | typealias Content = Button
68 | typealias ViewModel = Button.ViewModel
69 | typealias Coordinator = Void
70 |
71 | let viewModel: ViewModel
72 |
73 | init(viewModel: ViewModel) {
74 | self.viewModel = viewModel
75 | }
76 |
77 | func renderContent(coordinator: Coordinator) -> Button {
78 | Button()
79 | }
80 |
81 | func render(in content: Button, coordinator: Coordinator) {
82 | content.configure(viewModel: viewModel)
83 | }
84 |
85 | var layoutMode: ContentLayoutMode {
86 | .flexibleHeight(estimatedHeight: 44.0)
87 | }
88 | }
89 | ```
90 |
91 |
92 |
93 | ### Presentation
94 |
95 | We represent the list UI using List / Section / Cell. Not only that, but we can also map actions and layouts using modifiers.
96 |
97 | ```swift
98 | let list = List {
99 | Section(id: "Section1") {
100 | Cell(
101 | id: "Cell1",
102 | component: ButtonComponent(viewModel: .init(title: $0.rawValue))
103 | )
104 | Cell(
105 | id: "Cell2",
106 | component: ButtonComponent(viewModel: .init(title: $0.rawValue))
107 | )
108 | .didSelect { context in
109 | // handle selection
110 | }
111 | .willDisplay { context in
112 | // handle displaying
113 | }
114 | }
115 | .withHeader(ButtonComponent(viewModel: .init(title: "Header")))
116 | .withFooter(ButtonComponent(viewModel: .init(title: "Footer")))
117 | .withSectionLayout(.vertical(spacing: 12.0))
118 | }
119 |
120 | collectionViewAdapter.apply(
121 | list,
122 | updateStrategy: .animatedBatchUpdates // default is animatedBatchUpdates
123 | ) {
124 | // after completion
125 | }
126 | ```
127 |
128 | The first parameter, `list`, represents the new List structure that defines the sections, cells, and their components to be displayed in the UICollectionView.
129 |
130 | The second parameter, `updateStrategy`, is an enum of type `CollectionViewAdapterUpdateStrategy`, allowing users to control how the collection view updates its contents.
131 |
132 | Three options are provided:
133 | - `case animatedBatchUpdates:` Performs animated batch updates by calling `performBatchUpdates(…)` with the new content. This approach animates the insertions, deletions, and moves of cells, creating a smooth user experience.
134 | - `case nonanimatedBatchUpdates:` Performs the batch updates without animation by wrapping them in a `UIView.performWithoutAnimation(…)` block. This is more performant than reloadData() as it avoids full reconfiguration of all visible cells.
135 | - `case reloadData:` Performs a full reload of the data using `reloadData()`. This reconfigures all visible cells and is generally discouraged for updating data due to performance implications. UIKit engineers recommend preferring batch updates over full reloads.
136 |
137 | By default, the value is set to `.animatedBatchUpdates`, which provides a visually smooth and performant way to update the collection view when the data changes.
138 |
139 | The third parameter is a trailing closure that acts as a completion handler. This block is executed after the collection view has finished applying the changes.
140 |
141 |
142 | ### Sizing
143 |
144 | The size of a View is actually adjusted when the View is displayed on the screen, and this can be adjusted through `sizeThatFits`. Until then, the component can represent its own size as an estimate.
145 |
146 | ```swift
147 | struct ButtonComponent: Component {
148 | typealias Content = Button
149 | // ...
150 | var layoutMode: ContentLayoutMode {
151 | .flexibleHeight(estimatedHeight: 44.0)
152 | }
153 | }
154 |
155 | final class Button: UIControl {
156 | // ...
157 | override func sizeThatFits(_ size: CGSize) -> CGSize {
158 | // return size of a Button
159 | }
160 | }
161 | ```
162 |
163 | SectionLayout is tightly coupled with `UICollectionViewCompositionalLayout`, providing a custom interface to return an `NSCollectionLayoutSection`.
164 |
165 | ```swift
166 | Section(id: "Section1") {
167 | // ...
168 | }
169 | .withSectionLayout { [weak self] context -> NSCollectionLayoutSection? in
170 | // return NSCollectionLayoutSection object
171 | }
172 | ```
173 |
174 |
175 |
176 | ### Pagination
177 | `KarrotListKit` provides an easy-to-use interface for handling pagination when loading the next page of data.
178 | While traditionally, this might be implemented within a `scrollViewDidScroll` method, `KarrotListKit` offers a more structured mechanism for this purpose.
179 |
180 | `List` provides an `onReachEnd` modifier, which is called when the end of the list is reached. This modifier can be attached to a `List`.
181 |
182 | ```swift
183 | List(sections: [])
184 | .onReachEnd(
185 | offset: .absolute(100.0),
186 | handler: { _ in
187 | // Closure Trigger when reached end of list.
188 | }
189 | )
190 | ```
191 |
192 | The first parameter, `offset`, is an enum of type `ReachedEndEvent.OffsetFromEnd`, allowing users to set the trigger condition.
193 |
194 | Two options are provided:
195 |
196 | - `case relativeToContainerSize(multiplier: CGFloat)`: Triggers the event when the user scrolls within a multiple of the height of the content view.
197 | - `case absolute(CGFloat)`: Triggers the event when the user scrolls within an absolute point value from the end.
198 |
199 | By default, the value is set to `.relativeToContainerSize(multiplier: 2.0)`, which triggers the event when the scroll position is within twice the height of the list view from the end of the list.
200 |
201 | The second parameter, `handler`, is the callback handler that performs an asynchronous action when reached end of the list.
202 |
203 |
204 |
205 | ### Prefetching
206 |
207 | Provides prefetching of resources API to improve scroll performance.
208 | The CollectionViewAdapter conforms to the `UICollectionViewDataSourcePrefetching`. The framework provides the `ComponentResourcePrefetchable` and `CollectionViewPrefetchingPlugin` protocols for compatibility.
209 |
210 | Below is sample code for Image prefetching.
211 |
212 | ```swift
213 | let collectionViewAdapter = CollectionViewAdapter(
214 | configuration: .init(),
215 | collectionView: collectionView,
216 | layoutAdapter: CollectionViewLayoutAdapter(),
217 | prefetchingPlugins: [
218 | RemoteImagePrefetchingPlugin(
219 | remoteImagePrefetcher: RemoteImagePrefetcher()
220 | )
221 | ]
222 | )
223 |
224 | extension ImagePrefetchableComponent: ComponentRemoteImagePrefetchable {
225 | var remoteImageURLs: [URL] {
226 | [
227 | URL(string: "imageURL"),
228 | URL(string: "imageURL"),
229 | URL(string: "imageURL")
230 | ]
231 | }
232 | }
233 | ```
234 |
235 |
236 |
237 | ## Contributing
238 |
239 | We warmly welcome and appreciate any contributions to this project!
240 | Feel free to submit pull requests to enhance the functionalities of this project.
241 |
242 |
243 |
244 | ## License
245 |
246 | This project is licensed under the Apache License 2.0. See [LICENSE](https://github.com/daangn/KarrotListKit/blob/main/LICENSE) for details.
247 |
248 |
--------------------------------------------------------------------------------
/Sources/KarrotListKit/Utils/Chunked.swift:
--------------------------------------------------------------------------------
1 | //===----------------------------------------------------------------------===//
2 | //
3 | // This source file is part of the Swift Algorithms open source project
4 | //
5 | // Copyright (c) 2020 Apple Inc. and the Swift project authors
6 | // Licensed under Apache License v2.0 with Runtime Library Exception
7 | //
8 | // See https://swift.org/LICENSE.txt for license information
9 | //
10 | //===----------------------------------------------------------------------===//
11 |
12 | //===----------------------------------------------------------------------===//
13 | // chunks(ofCount:)
14 | //===----------------------------------------------------------------------===//
15 |
16 | /// A collection that presents the elements of its base collection in
17 | /// `SubSequence` chunks of any given count.
18 | ///
19 | /// A `ChunksOfCountCollection` is a lazy view on the base `Collection`, but it
20 | /// does not implicitly confer laziness on algorithms applied to its result. In
21 | /// other words, for ordinary collections `c`:ChunksOfCountCollection
22 | ///
23 | /// * `c.chunks(ofCount: 3)` does not create new storage
24 | /// * `c.chunks(ofCount: 3).map(f)` maps eagerly and returns a new array
25 | /// * `c.lazy.chunks(ofCount: 3).map(f)` maps lazily and returns a
26 | /// `LazyMapCollection`
27 | struct ChunksOfCountCollection {
28 | typealias Element = Base.SubSequence
29 |
30 | @usableFromInline
31 | let base: Base
32 |
33 | @usableFromInline
34 | let chunkCount: Int
35 |
36 | @usableFromInline
37 | var endOfFirstChunk: Base.Index
38 |
39 | /// Creates a view instance that presents the elements of `base` in
40 | /// `SubSequence` chunks of the given count.
41 | ///
42 | /// - Complexity: O(*n*), because the start index is pre-computed.
43 | @inlinable
44 | init(_base: Base, _chunkCount: Int) {
45 | self.base = _base
46 | self.chunkCount = _chunkCount
47 |
48 | // Compute the start index upfront in order to make start index a O(1)
49 | // lookup.
50 | self.endOfFirstChunk = _base.index(
51 | _base.startIndex, offsetBy: _chunkCount,
52 | limitedBy: _base.endIndex
53 | ) ?? _base.endIndex
54 | }
55 | }
56 |
57 | extension ChunksOfCountCollection: Collection {
58 | struct Index {
59 | @usableFromInline
60 | let baseRange: Range
61 |
62 | @inlinable
63 | init(_baseRange: Range) {
64 | self.baseRange = _baseRange
65 | }
66 | }
67 |
68 | /// - Complexity: O(1)
69 | @inlinable
70 | var startIndex: Index {
71 | Index(_baseRange: base.startIndex.. Element {
82 | precondition(i != endIndex, "Index out of range")
83 | return base[i.baseRange]
84 | }
85 |
86 | @inlinable
87 | func index(after i: Index) -> Index {
88 | precondition(i != endIndex, "Advancing past end index")
89 | let baseIdx = base.index(
90 | i.baseRange.upperBound, offsetBy: chunkCount,
91 | limitedBy: base.endIndex
92 | ) ?? base.endIndex
93 | return Index(_baseRange: i.baseRange.upperBound.. Bool {
103 | lhs.baseRange.lowerBound == rhs.baseRange.lowerBound
104 | }
105 |
106 | @inlinable
107 | static func < (
108 | lhs: ChunksOfCountCollection.Index,
109 | rhs: ChunksOfCountCollection.Index
110 | ) -> Bool {
111 | lhs.baseRange.lowerBound < rhs.baseRange.lowerBound
112 | }
113 | }
114 |
115 | extension ChunksOfCountCollection:
116 | BidirectionalCollection, RandomAccessCollection
117 | where Base: RandomAccessCollection {
118 | @inlinable
119 | func index(before i: Index) -> Index {
120 | precondition(i != startIndex, "Advancing past start index")
121 |
122 | var offset = chunkCount
123 | if i.baseRange.lowerBound == base.endIndex {
124 | let remainder = base.count % chunkCount
125 | if remainder != 0 {
126 | offset = remainder
127 | }
128 | }
129 |
130 | let baseIdx = base.index(
131 | i.baseRange.lowerBound, offsetBy: -offset,
132 | limitedBy: base.startIndex
133 | ) ?? base.startIndex
134 | return Index(_baseRange: baseIdx.. Int {
141 | let distance =
142 | base.distance(
143 | from: start.baseRange.lowerBound,
144 | to: end.baseRange.lowerBound
145 | )
146 | let (quotient, remainder) =
147 | distance.quotientAndRemainder(dividingBy: chunkCount)
148 | return quotient + remainder.signum()
149 | }
150 |
151 | @inlinable
152 | var count: Int {
153 | let (quotient, remainder) =
154 | base.count.quotientAndRemainder(dividingBy: chunkCount)
155 | return quotient + remainder.signum()
156 | }
157 |
158 | @inlinable
159 | func index(
160 | _ i: Index, offsetBy offset: Int, limitedBy limit: Index
161 | ) -> Index? {
162 | guard offset != 0 else { return i }
163 | guard limit != i else { return nil }
164 |
165 | if offset > 0 {
166 | return limit > i
167 | ? offsetForward(i, offsetBy: offset, limit: limit)
168 | : offsetForward(i, offsetBy: offset)
169 | } else {
170 | return limit < i
171 | ? offsetBackward(i, offsetBy: offset, limit: limit)
172 | : offsetBackward(i, offsetBy: offset)
173 | }
174 | }
175 |
176 | @inlinable
177 | func index(_ i: Index, offsetBy distance: Int) -> Index {
178 | guard distance != 0 else { return i }
179 |
180 | let idx = distance > 0
181 | ? offsetForward(i, offsetBy: distance)
182 | : offsetBackward(i, offsetBy: distance)
183 | guard let index = idx else {
184 | fatalError("Out of bounds")
185 | }
186 | return index
187 | }
188 |
189 | @inlinable
190 | func offsetForward(
191 | _ i: Index, offsetBy distance: Int, limit: Index? = nil
192 | ) -> Index? {
193 | assert(distance > 0)
194 |
195 | return makeOffsetIndex(
196 | from: i, baseBound: base.endIndex,
197 | distance: distance, baseDistance: distance * chunkCount,
198 | limit: limit, by: >
199 | )
200 | }
201 |
202 | // Convenience to compute offset backward base distance.
203 | @inlinable
204 | func computeOffsetBackwardBaseDistance(
205 | _ i: Index, _ distance: Int
206 | ) -> Int {
207 | if i == endIndex {
208 | let remainder = base.count % chunkCount
209 | // We have to take it into account when calculating offsets.
210 | if remainder != 0 {
211 | // Distance "minus" one (at this point distance is negative) because we
212 | // need to adjust for the last position that have a variadic (remainder)
213 | // number of elements.
214 | return ((distance + 1) * chunkCount) - remainder
215 | }
216 | }
217 | return distance * chunkCount
218 | }
219 |
220 | @inlinable
221 | func offsetBackward(
222 | _ i: Index, offsetBy distance: Int, limit: Index? = nil
223 | ) -> Index? {
224 | assert(distance < 0)
225 | let baseDistance =
226 | computeOffsetBackwardBaseDistance(i, distance)
227 | return makeOffsetIndex(
228 | from: i, baseBound: base.startIndex,
229 | distance: distance, baseDistance: baseDistance,
230 | limit: limit, by: <
231 | )
232 | }
233 |
234 | // Helper to compute `index(offsetBy:)` index.
235 | @inlinable
236 | func makeOffsetIndex(
237 | from i: Index, baseBound: Base.Index, distance: Int, baseDistance: Int,
238 | limit: Index?, by limitFn: (Base.Index, Base.Index) -> Bool
239 | ) -> Index? {
240 | let baseIdx = base.index(
241 | i.baseRange.lowerBound, offsetBy: baseDistance,
242 | limitedBy: baseBound
243 | )
244 |
245 | if let limit {
246 | if baseIdx == nil {
247 | // If we passed the bounds while advancing forward, and the limit is the
248 | // `endIndex`, since the computation on `base` don't take into account
249 | // the remainder, we have to make sure that passing the bound was
250 | // because of the distance not just because of a remainder. Special
251 | // casing is less expensive than always using `count` (which could be
252 | // O(n) for non-random access collection base) to compute the base
253 | // distance taking remainder into account.
254 | if baseDistance > 0, limit == endIndex {
255 | if self.distance(from: i, to: limit) < distance {
256 | return nil
257 | }
258 | } else {
259 | return nil
260 | }
261 | }
262 |
263 | // Checks for the limit.
264 | let baseStartIdx = baseIdx ?? baseBound
265 | if limitFn(baseStartIdx, limit.baseRange.lowerBound) {
266 | return nil
267 | }
268 | }
269 |
270 | let baseStartIdx = baseIdx ?? baseBound
271 | let baseEndIdx = base.index(
272 | baseStartIdx, offsetBy: chunkCount, limitedBy: base.endIndex
273 | ) ?? base.endIndex
274 |
275 | return Index(_baseRange: baseStartIdx..` view presenting the elements in
281 | /// chunks with count of the given count parameter.
282 | ///
283 | /// - Parameter count: The size of the chunks. If the `count` parameter is
284 | /// evenly divided by the count of the base `Collection` all the chunks will
285 | /// have the count equals to size. Otherwise, the last chunk will contain
286 | /// the remaining elements.
287 | ///
288 | /// let c = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
289 | /// print(c.chunks(ofCount: 5).map(Array.init))
290 | /// // [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]
291 | ///
292 | /// print(c.chunks(ofCount: 3).map(Array.init))
293 | /// // [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
294 | ///
295 | /// - Complexity: O(*n*), because the start index is pre-computed.
296 | func chunks(ofCount count: Int) -> ChunksOfCountCollection {
297 | precondition(count > 0, "Cannot chunk with count <= 0!")
298 | return ChunksOfCountCollection(_base: self, _chunkCount: count)
299 | }
300 | }
301 |
302 | extension ChunksOfCountCollection.Index: Hashable where Base.Index: Hashable {}
303 |
304 | extension ChunksOfCountCollection:
305 | LazySequenceProtocol,
306 | LazyCollectionProtocol
307 | where Base: LazySequenceProtocol {}
308 |
309 | extension Array {
310 | func chunks(ofCount count: Int) -> [[Element]] {
311 | let chunksOfCountCollection: ChunksOfCountCollection = chunks(ofCount: count)
312 | return chunksOfCountCollection.map(Array.init)
313 | }
314 | }
315 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Examples/KarrotListKitSampleApp/KarrotListKitSampleApp.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 77;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 6C1530AE2E12A160000B37A2 /* KarrotListKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C1530AD2E12A160000B37A2 /* KarrotListKit */; };
11 | 6C682E3D2E14C7DB00F816C0 /* PinLayout in Frameworks */ = {isa = PBXBuildFile; productRef = 6C682E3C2E14C7DB00F816C0 /* PinLayout */; };
12 | 6C682E402E14C7F800F816C0 /* FlexLayout in Frameworks */ = {isa = PBXBuildFile; productRef = 6C682E3F2E14C7F800F816C0 /* FlexLayout */; };
13 | /* End PBXBuildFile section */
14 |
15 | /* Begin PBXFileReference section */
16 | 6C1530882E12966F000B37A2 /* KarrotListKitSampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KarrotListKitSampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
17 | 6C1530A52E12A0CF000B37A2 /* KarrotListKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = KarrotListKit; path = ../..; sourceTree = ""; };
18 | /* End PBXFileReference section */
19 |
20 | /* Begin PBXFileSystemSynchronizedRootGroup section */
21 | 6C15308A2E12966F000B37A2 /* KarrotListKitSampleApp */ = {
22 | isa = PBXFileSystemSynchronizedRootGroup;
23 | path = KarrotListKitSampleApp;
24 | sourceTree = "";
25 | };
26 | /* End PBXFileSystemSynchronizedRootGroup section */
27 |
28 | /* Begin PBXFrameworksBuildPhase section */
29 | 6C1530852E12966F000B37A2 /* Frameworks */ = {
30 | isa = PBXFrameworksBuildPhase;
31 | buildActionMask = 2147483647;
32 | files = (
33 | 6C682E402E14C7F800F816C0 /* FlexLayout in Frameworks */,
34 | 6C682E3D2E14C7DB00F816C0 /* PinLayout in Frameworks */,
35 | 6C1530AE2E12A160000B37A2 /* KarrotListKit in Frameworks */,
36 | );
37 | runOnlyForDeploymentPostprocessing = 0;
38 | };
39 | /* End PBXFrameworksBuildPhase section */
40 |
41 | /* Begin PBXGroup section */
42 | 6C15307F2E12966F000B37A2 = {
43 | isa = PBXGroup;
44 | children = (
45 | 6C1530A52E12A0CF000B37A2 /* KarrotListKit */,
46 | 6C15308A2E12966F000B37A2 /* KarrotListKitSampleApp */,
47 | 6C1530892E12966F000B37A2 /* Products */,
48 | );
49 | indentWidth = 2;
50 | sourceTree = "";
51 | tabWidth = 2;
52 | };
53 | 6C1530892E12966F000B37A2 /* Products */ = {
54 | isa = PBXGroup;
55 | children = (
56 | 6C1530882E12966F000B37A2 /* KarrotListKitSampleApp.app */,
57 | );
58 | name = Products;
59 | sourceTree = "";
60 | };
61 | /* End PBXGroup section */
62 |
63 | /* Begin PBXNativeTarget section */
64 | 6C1530872E12966F000B37A2 /* KarrotListKitSampleApp */ = {
65 | isa = PBXNativeTarget;
66 | buildConfigurationList = 6C1530932E129671000B37A2 /* Build configuration list for PBXNativeTarget "KarrotListKitSampleApp" */;
67 | buildPhases = (
68 | 6C1530842E12966F000B37A2 /* Sources */,
69 | 6C1530852E12966F000B37A2 /* Frameworks */,
70 | 6C1530862E12966F000B37A2 /* Resources */,
71 | );
72 | buildRules = (
73 | );
74 | dependencies = (
75 | );
76 | fileSystemSynchronizedGroups = (
77 | 6C15308A2E12966F000B37A2 /* KarrotListKitSampleApp */,
78 | );
79 | name = KarrotListKitSampleApp;
80 | packageProductDependencies = (
81 | 6C1530AD2E12A160000B37A2 /* KarrotListKit */,
82 | 6C682E3C2E14C7DB00F816C0 /* PinLayout */,
83 | 6C682E3F2E14C7F800F816C0 /* FlexLayout */,
84 | );
85 | productName = KarrotListKitSampleApp;
86 | productReference = 6C1530882E12966F000B37A2 /* KarrotListKitSampleApp.app */;
87 | productType = "com.apple.product-type.application";
88 | };
89 | /* End PBXNativeTarget section */
90 |
91 | /* Begin PBXProject section */
92 | 6C1530802E12966F000B37A2 /* Project object */ = {
93 | isa = PBXProject;
94 | attributes = {
95 | BuildIndependentTargetsInParallel = 1;
96 | LastSwiftUpdateCheck = 1630;
97 | LastUpgradeCheck = 1630;
98 | ORGANIZATIONNAME = "Danggeun Market Inc";
99 | TargetAttributes = {
100 | 6C1530872E12966F000B37A2 = {
101 | CreatedOnToolsVersion = 16.3;
102 | };
103 | };
104 | };
105 | buildConfigurationList = 6C1530832E12966F000B37A2 /* Build configuration list for PBXProject "KarrotListKitSampleApp" */;
106 | developmentRegion = en;
107 | hasScannedForEncodings = 0;
108 | knownRegions = (
109 | en,
110 | Base,
111 | );
112 | mainGroup = 6C15307F2E12966F000B37A2;
113 | minimizedProjectReferenceProxies = 1;
114 | packageReferences = (
115 | 6C1530AC2E12A160000B37A2 /* XCLocalSwiftPackageReference "../.." */,
116 | 6C682E3B2E14C7DB00F816C0 /* XCRemoteSwiftPackageReference "PinLayout" */,
117 | 6C682E3E2E14C7F800F816C0 /* XCRemoteSwiftPackageReference "FlexLayout" */,
118 | );
119 | preferredProjectObjectVersion = 77;
120 | productRefGroup = 6C1530892E12966F000B37A2 /* Products */;
121 | projectDirPath = "";
122 | projectRoot = "";
123 | targets = (
124 | 6C1530872E12966F000B37A2 /* KarrotListKitSampleApp */,
125 | );
126 | };
127 | /* End PBXProject section */
128 |
129 | /* Begin PBXResourcesBuildPhase section */
130 | 6C1530862E12966F000B37A2 /* Resources */ = {
131 | isa = PBXResourcesBuildPhase;
132 | buildActionMask = 2147483647;
133 | files = (
134 | );
135 | runOnlyForDeploymentPostprocessing = 0;
136 | };
137 | /* End PBXResourcesBuildPhase section */
138 |
139 | /* Begin PBXSourcesBuildPhase section */
140 | 6C1530842E12966F000B37A2 /* Sources */ = {
141 | isa = PBXSourcesBuildPhase;
142 | buildActionMask = 2147483647;
143 | files = (
144 | );
145 | runOnlyForDeploymentPostprocessing = 0;
146 | };
147 | /* End PBXSourcesBuildPhase section */
148 |
149 | /* Begin XCBuildConfiguration section */
150 | 6C1530912E129671000B37A2 /* Debug */ = {
151 | isa = XCBuildConfiguration;
152 | buildSettings = {
153 | ALWAYS_SEARCH_USER_PATHS = NO;
154 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
155 | CLANG_ANALYZER_NONNULL = YES;
156 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
157 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
158 | CLANG_ENABLE_MODULES = YES;
159 | CLANG_ENABLE_OBJC_ARC = YES;
160 | CLANG_ENABLE_OBJC_WEAK = YES;
161 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
162 | CLANG_WARN_BOOL_CONVERSION = YES;
163 | CLANG_WARN_COMMA = YES;
164 | CLANG_WARN_CONSTANT_CONVERSION = YES;
165 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
166 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
167 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
168 | CLANG_WARN_EMPTY_BODY = YES;
169 | CLANG_WARN_ENUM_CONVERSION = YES;
170 | CLANG_WARN_INFINITE_RECURSION = YES;
171 | CLANG_WARN_INT_CONVERSION = YES;
172 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
173 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
174 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
175 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
176 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
177 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
178 | CLANG_WARN_STRICT_PROTOTYPES = YES;
179 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
180 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
181 | CLANG_WARN_UNREACHABLE_CODE = YES;
182 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
183 | COPY_PHASE_STRIP = NO;
184 | DEBUG_INFORMATION_FORMAT = dwarf;
185 | ENABLE_STRICT_OBJC_MSGSEND = YES;
186 | ENABLE_TESTABILITY = YES;
187 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
188 | GCC_C_LANGUAGE_STANDARD = gnu17;
189 | GCC_DYNAMIC_NO_PIC = NO;
190 | GCC_NO_COMMON_BLOCKS = YES;
191 | GCC_OPTIMIZATION_LEVEL = 0;
192 | GCC_PREPROCESSOR_DEFINITIONS = (
193 | "DEBUG=1",
194 | "$(inherited)",
195 | );
196 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
197 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
198 | GCC_WARN_UNDECLARED_SELECTOR = YES;
199 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
200 | GCC_WARN_UNUSED_FUNCTION = YES;
201 | GCC_WARN_UNUSED_VARIABLE = YES;
202 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
203 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
204 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
205 | MTL_FAST_MATH = YES;
206 | ONLY_ACTIVE_ARCH = YES;
207 | SDKROOT = iphoneos;
208 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
209 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
210 | };
211 | name = Debug;
212 | };
213 | 6C1530922E129671000B37A2 /* Release */ = {
214 | isa = XCBuildConfiguration;
215 | buildSettings = {
216 | ALWAYS_SEARCH_USER_PATHS = NO;
217 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
218 | CLANG_ANALYZER_NONNULL = YES;
219 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
220 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
221 | CLANG_ENABLE_MODULES = YES;
222 | CLANG_ENABLE_OBJC_ARC = YES;
223 | CLANG_ENABLE_OBJC_WEAK = YES;
224 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
225 | CLANG_WARN_BOOL_CONVERSION = YES;
226 | CLANG_WARN_COMMA = YES;
227 | CLANG_WARN_CONSTANT_CONVERSION = YES;
228 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
229 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
230 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
231 | CLANG_WARN_EMPTY_BODY = YES;
232 | CLANG_WARN_ENUM_CONVERSION = YES;
233 | CLANG_WARN_INFINITE_RECURSION = YES;
234 | CLANG_WARN_INT_CONVERSION = YES;
235 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
236 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
237 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
238 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
239 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
240 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
241 | CLANG_WARN_STRICT_PROTOTYPES = YES;
242 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
243 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
244 | CLANG_WARN_UNREACHABLE_CODE = YES;
245 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
246 | COPY_PHASE_STRIP = NO;
247 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
248 | ENABLE_NS_ASSERTIONS = NO;
249 | ENABLE_STRICT_OBJC_MSGSEND = YES;
250 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
251 | GCC_C_LANGUAGE_STANDARD = gnu17;
252 | GCC_NO_COMMON_BLOCKS = YES;
253 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
254 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
255 | GCC_WARN_UNDECLARED_SELECTOR = YES;
256 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
257 | GCC_WARN_UNUSED_FUNCTION = YES;
258 | GCC_WARN_UNUSED_VARIABLE = YES;
259 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
260 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
261 | MTL_ENABLE_DEBUG_INFO = NO;
262 | MTL_FAST_MATH = YES;
263 | SDKROOT = iphoneos;
264 | SWIFT_COMPILATION_MODE = wholemodule;
265 | VALIDATE_PRODUCT = YES;
266 | };
267 | name = Release;
268 | };
269 | 6C1530942E129671000B37A2 /* Debug */ = {
270 | isa = XCBuildConfiguration;
271 | buildSettings = {
272 | CODE_SIGN_STYLE = Automatic;
273 | CURRENT_PROJECT_VERSION = 1;
274 | ENABLE_PREVIEWS = YES;
275 | GENERATE_INFOPLIST_FILE = YES;
276 | INFOPLIST_KEY_CFBundleDisplayName = KarrotListKit;
277 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
278 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
279 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
280 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
281 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
282 | LD_RUNPATH_SEARCH_PATHS = (
283 | "$(inherited)",
284 | "@executable_path/Frameworks",
285 | );
286 | MARKETING_VERSION = 1.0;
287 | PRODUCT_BUNDLE_IDENTIFIER = com.daangn.KarrotListKitSampleApp;
288 | PRODUCT_NAME = "$(TARGET_NAME)";
289 | SWIFT_EMIT_LOC_STRINGS = YES;
290 | SWIFT_VERSION = 5.0;
291 | TARGETED_DEVICE_FAMILY = "1,2";
292 | };
293 | name = Debug;
294 | };
295 | 6C1530952E129671000B37A2 /* Release */ = {
296 | isa = XCBuildConfiguration;
297 | buildSettings = {
298 | CODE_SIGN_STYLE = Automatic;
299 | CURRENT_PROJECT_VERSION = 1;
300 | ENABLE_PREVIEWS = YES;
301 | GENERATE_INFOPLIST_FILE = YES;
302 | INFOPLIST_KEY_CFBundleDisplayName = KarrotListKit;
303 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
304 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
305 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
306 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
307 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
308 | LD_RUNPATH_SEARCH_PATHS = (
309 | "$(inherited)",
310 | "@executable_path/Frameworks",
311 | );
312 | MARKETING_VERSION = 1.0;
313 | PRODUCT_BUNDLE_IDENTIFIER = com.daangn.KarrotListKitSampleApp;
314 | PRODUCT_NAME = "$(TARGET_NAME)";
315 | SWIFT_EMIT_LOC_STRINGS = YES;
316 | SWIFT_VERSION = 5.0;
317 | TARGETED_DEVICE_FAMILY = "1,2";
318 | };
319 | name = Release;
320 | };
321 | /* End XCBuildConfiguration section */
322 |
323 | /* Begin XCConfigurationList section */
324 | 6C1530832E12966F000B37A2 /* Build configuration list for PBXProject "KarrotListKitSampleApp" */ = {
325 | isa = XCConfigurationList;
326 | buildConfigurations = (
327 | 6C1530912E129671000B37A2 /* Debug */,
328 | 6C1530922E129671000B37A2 /* Release */,
329 | );
330 | defaultConfigurationIsVisible = 0;
331 | defaultConfigurationName = Release;
332 | };
333 | 6C1530932E129671000B37A2 /* Build configuration list for PBXNativeTarget "KarrotListKitSampleApp" */ = {
334 | isa = XCConfigurationList;
335 | buildConfigurations = (
336 | 6C1530942E129671000B37A2 /* Debug */,
337 | 6C1530952E129671000B37A2 /* Release */,
338 | );
339 | defaultConfigurationIsVisible = 0;
340 | defaultConfigurationName = Release;
341 | };
342 | /* End XCConfigurationList section */
343 |
344 | /* Begin XCLocalSwiftPackageReference section */
345 | 6C1530AC2E12A160000B37A2 /* XCLocalSwiftPackageReference "../.." */ = {
346 | isa = XCLocalSwiftPackageReference;
347 | relativePath = ../..;
348 | };
349 | /* End XCLocalSwiftPackageReference section */
350 |
351 | /* Begin XCRemoteSwiftPackageReference section */
352 | 6C682E3B2E14C7DB00F816C0 /* XCRemoteSwiftPackageReference "PinLayout" */ = {
353 | isa = XCRemoteSwiftPackageReference;
354 | repositoryURL = "https://github.com/layoutBox/PinLayout.git";
355 | requirement = {
356 | kind = upToNextMajorVersion;
357 | minimumVersion = 1.0.0;
358 | };
359 | };
360 | 6C682E3E2E14C7F800F816C0 /* XCRemoteSwiftPackageReference "FlexLayout" */ = {
361 | isa = XCRemoteSwiftPackageReference;
362 | repositoryURL = "https://github.com/layoutBox/FlexLayout.git";
363 | requirement = {
364 | kind = upToNextMajorVersion;
365 | minimumVersion = 2.0.0;
366 | };
367 | };
368 | /* End XCRemoteSwiftPackageReference section */
369 |
370 | /* Begin XCSwiftPackageProductDependency section */
371 | 6C1530AD2E12A160000B37A2 /* KarrotListKit */ = {
372 | isa = XCSwiftPackageProductDependency;
373 | productName = KarrotListKit;
374 | };
375 | 6C682E3C2E14C7DB00F816C0 /* PinLayout */ = {
376 | isa = XCSwiftPackageProductDependency;
377 | package = 6C682E3B2E14C7DB00F816C0 /* XCRemoteSwiftPackageReference "PinLayout" */;
378 | productName = PinLayout;
379 | };
380 | 6C682E3F2E14C7F800F816C0 /* FlexLayout */ = {
381 | isa = XCSwiftPackageProductDependency;
382 | package = 6C682E3E2E14C7F800F816C0 /* XCRemoteSwiftPackageReference "FlexLayout" */;
383 | productName = FlexLayout;
384 | };
385 | /* End XCSwiftPackageProductDependency section */
386 | };
387 | rootObject = 6C1530802E12966F000B37A2 /* Project object */;
388 | }
389 |
--------------------------------------------------------------------------------
|