├── .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://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdaangn%2FKarrotListKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/daangn/KarrotListKit) 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdaangn%2FKarrotListKit%2Fbadge%3Ftype%3Dplatforms)](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 | --------------------------------------------------------------------------------