diff --git a/.agents/specs/835-collection-ui-removal.md b/.agents/specs/835-collection-ui-removal.md new file mode 100644 index 00000000..9b4ff954 --- /dev/null +++ b/.agents/specs/835-collection-ui-removal.md @@ -0,0 +1,68 @@ +# CollectionUI 제거 Spec + +- Source: https://github.com/opficdev/DevLog_iOS/issues/835 +- Approved Designer Result: 2026-08-27 사용자 승인 +- User approval: `ㄱㄱ` + +## Constraints + +- `TodoListView`의 현재 SwiftUI `List` 구성과 `safeAreaInset`, `onScrollOffsetChange`, `.navigationTitle`, `.refreshable`, 로딩, 빈 목록, 검색, 상세 이동, 페이지네이션, 스와이프 동작을 변경하지 않는다. +- `TodoItemRow`와 `TagList(tags, lineLimit: 1)` 계약을 유지한다. +- `Application/Presentation/Project.swift`와 PresentationShared는 이미 CollectionUI 의존성과 import가 없으므로 변경하지 않는다. +- 새 UIKit 목록 renderer, Domain, Data, Firebase, 관련 이슈 #815, #816, #817, #834는 변경하지 않는다. +- 생성된 `.xcodeproj`, `.xcworkspace`, `DerivedData`, `Package.resolved`는 추적하지 않는다. + +## Alternatives and decision + +- `71b5ac1b7` 전체 되돌리기는 CollectionUI와 무관한 변경을 함께 되돌리므로 사용하지 않는다. +- `Libraries/CollectionUI`만 삭제하면 Workspace와 CI의 끊어진 참조가 남으므로 사용하지 않는다. +- 현재 SwiftUI `TodoListView`와 Presentation 경계는 그대로 두고 Workspace, CI, 구조도 연결을 제거한 뒤 고립된 `Libraries/CollectionUI`를 삭제한다. + +## Changed boundaries + +- Workspace에서 `Libraries/CollectionUI` 프로젝트와 `CollectionUITests`를 제거한다. +- CI Libraries 묶음은 `MarkdownRenderer`만 유지하고 `CollectionUITests` 전용 테스트 경로 분기를 제거한다. +- Todo 목록의 렌더링과 스크롤 계약은 PresentationShared의 SwiftUI `List`가 계속 소유한다. +- `docs/graph.png`에서 제거된 `CollectionUI` 노드를 없앤다. + +## Acceptance criteria + +- [ ] `Libraries/CollectionUI`의 추적 파일과 생성물이 모두 제거된다. +- [ ] `Workspace.swift`에 `Libraries/CollectionUI` 등록이 없다. +- [ ] `.github/workflows/ci.yml`에 `CollectionUITests` scheme과 `Libraries/CollectionUI/Tests` 분기가 없다. +- [ ] Spec을 제외한 추적 파일 전체에서 `CollectionUI`, `CollectionUITests`, `TodoListCollectionView`, `TodoListCollectionCell`, `CollectionRenderingSnapshot` 참조가 없다. +- [ ] `docs/graph.png`에 `CollectionUI` 노드가 없고 나머지 모듈 관계가 유지된다. +- [ ] `Application/Presentation/Project.swift`에 CollectionUI 의존성이 없으며 불필요한 변경이 없다. +- [ ] `TodoListView.swift`의 기존 SwiftUI `List`와 화면 계약이 유지된다. +- [ ] `TodoItemRow`와 `TagList(tags, lineLimit: 1)` 계약이 유지된다. +- [ ] Domain, Data, Firebase와 관련 이슈 #815, #816, #817, #834에 변경이 없다. +- [ ] `tuist generate --no-open`과 `App` scheme build가 성공하고 생성물 변경이 추적되지 않는다. + +## Verification + +- Command: `git grep -n -E 'CollectionUI|CollectionUITests|TodoListCollection(View|Cell)|CollectionRenderingSnapshot' -- ':!.agents/specs/835-collection-ui-removal.md'` +- Evidence: 결과 없음 +- Command: `git ls-files Libraries/CollectionUI` +- Evidence: 결과 없음 +- Command: `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml", aliases: true)'` +- Evidence: 정상 종료 +- Command: `mise exec -- tuist generate --no-open` +- Evidence: 정상 종료 +- Command: `xcodebuild -workspace DevLog.xcworkspace -scheme App -configuration Debug -destination 'generic/platform=iOS Simulator' -skipPackagePluginValidation -skipMacroValidation -showBuildTimingSummary build` +- Evidence: 정상 종료 +- Command: `git diff --check` +- Evidence: 정상 종료 +- 실기기 헤더와 `navigationTitle` 동작은 현재 실행 권한으로 검증하지 않는다. + +## Minimum commit units + +1. `chore: CollectionUI 빌드 그래프와 CI 연결 제거` + - `.agents/specs/835-collection-ui-removal.md`, `Workspace.swift`, `.github/workflows/ci.yml`, `docs/graph.png` +2. `refactor: CollectionUI 라이브러리 제거` + - `Libraries/CollectionUI/**` + +## Execution constraints + +- app or Simulator execution: 금지 +- External writes: 로컬 커밋만 허용 +- CI or PR actions: 금지 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf694333..763397b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,7 @@ jobs: - name: Persistence schemes: "Persistence" - name: Libraries - schemes: "MarkdownRenderer CollectionUITests" + schemes: "MarkdownRenderer" - name: Presentation schemes: "Entry PresentationShared HomeTab" - name: Widget @@ -429,8 +429,6 @@ jobs: LOG_PATH="$RESULT_DIR/${TEST_SCHEME}.log" if [ "$TEST_SCHEME" = "WidgetCore" ]; then TEST_SOURCE_DIR="Widget/WidgetCore/Tests" - elif [ "$TEST_SCHEME" = "CollectionUITests" ]; then - TEST_SOURCE_DIR="Libraries/CollectionUI/Tests" elif [ -d "Libraries/${TEST_SCHEME}/Tests" ]; then TEST_SOURCE_DIR="Libraries/${TEST_SCHEME}/Tests" elif [ -d "Application/Presentation/${TEST_SCHEME}/Tests" ]; then diff --git a/Libraries/CollectionUI/Project.swift b/Libraries/CollectionUI/Project.swift deleted file mode 100644 index b5e74463..00000000 --- a/Libraries/CollectionUI/Project.swift +++ /dev/null @@ -1,69 +0,0 @@ -import ProjectDescription -import ProjectDescriptionHelpers - -let deploymentSettings: SettingsDictionary = [ - "IPHONEOS_DEPLOYMENT_TARGET": "17.0", - "MARKETING_VERSION": "1.0.0", -] - -let project = Project( - name: "CollectionUI", - packages: [], - settings: .devlogProject(additionalBase: deploymentSettings), - targets: [ - .target( - name: "CollectionUI", - destinations: .iOS, - product: .framework, - bundleId: "com.opfic.DevLog.CollectionUI", - infoPlist: .extendingDefault( - with: [ - "CFBundlePackageType": "FMWK", - ] - ), - sources: [ - "Sources/**/*.swift", - ], - scripts: [ - DevLogScripts.swiftLint( - sourcePath: "Sources", - configPath: "Sources/.swiftlint.yml" - ), - ], - dependencies: [], - settings: .devlog( - base: deploymentSettings - ) - ), - .target( - name: "CollectionUITests", - destinations: .iOS, - product: .unitTests, - bundleId: "com.opfic.DevLog.CollectionUITests", - infoPlist: .extendingDefault( - with: [ - "CFBundlePackageType": "BNDL", - ] - ), - sources: [ - "Tests/**/*.swift", - ], - scripts: [ - DevLogScripts.swiftLint( - sourcePath: "Tests", - configPath: "Tests/.swiftlint.yml" - ), - ], - dependencies: [ - .target(name: "CollectionUI"), - ], - settings: .devlog( - base: deploymentSettings.merging( - [ - "TEST_TARGET_NAME": "CollectionUI", - ] - ) { _, new in new } - ) - ), - ] -) diff --git a/Libraries/CollectionUI/README.md b/Libraries/CollectionUI/README.md deleted file mode 100644 index 4d919e76..00000000 --- a/Libraries/CollectionUI/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# CollectionUI - -`CollectionUI`는 SwiftUI 화면에서 UIKit `UICollectionView`를 연결하는 내부 공용 Library임. - -```swift -CollectionView( - snapshot: .init(sections: [ - .init(identifier: .default, itemIdentifiers: todos.map(\.id)) - ]), - layoutProvider: { _, _ in makeTodoLayout() }, - cellProvider: { collectionView, indexPath, identifier in - collectionView.dequeueConfiguredReusableCell( - using: todoCellRegistration, - for: indexPath, - item: identifier - ) - } -) -.onSelect { identifier in - store.send(.view(.select(identifier))) -} -.swipeActions(edge: .trailing) { identifier in - [deleteAction(identifier)] -} -.refreshable { - await store.send(.view(.refresh)).finish() -} -``` - -Cell은 `UICollectionViewCell`과 UIKit `UIView`로만 구성하며, `UIHostingController`, `UIHostingConfiguration`, `AnyView`는 사용하지 않음. diff --git a/Libraries/CollectionUI/Sources/.swiftlint.yml b/Libraries/CollectionUI/Sources/.swiftlint.yml deleted file mode 100644 index 1242ffca..00000000 --- a/Libraries/CollectionUI/Sources/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/Libraries/CollectionUI/Sources/CollectionRenderingSnapshot.swift b/Libraries/CollectionUI/Sources/CollectionRenderingSnapshot.swift deleted file mode 100644 index 2312c914..00000000 --- a/Libraries/CollectionUI/Sources/CollectionRenderingSnapshot.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// CollectionRenderingSnapshot.swift -// CollectionUI -// -// Created by opfic on 8/20/26. -// - -import Foundation - -// Collection 화면에 적용할 section과 item 식별자 상태 -public struct CollectionRenderingSnapshot: Equatable, Sendable -where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { - // 하나의 section과 그 안의 item 식별자 순서 - public struct Section: Equatable, Sendable { - // section을 구분하는 고유 식별자 - public let identifier: SectionIdentifier - // section 안에 표시할 item의 순서가 있는 식별자 목록 - public let itemIdentifiers: [ItemIdentifier] - - // section 식별자와 표시 순서를 지정한 section 값 생성 - public init( - identifier: SectionIdentifier, - itemIdentifiers: [ItemIdentifier] - ) { - self.identifier = identifier - self.itemIdentifiers = itemIdentifiers - } - } - - // 표시할 section과 item 식별자의 전체 순서 - public let sections: [Section] - // 같은 식별자를 유지한 채 다시 구성할 item 식별자 집합 - public let reconfiguredItemIdentifiers: Set - // 같은 식별자를 유지한 채 다시 구성할 section 식별자 집합 - public let reconfiguredSectionIdentifiers: Set - - // section 순서와 다시 구성할 item 식별자를 지정한 rendering snapshot 생성 - public init( - sections: [Section], - reconfiguredItemIdentifiers: Set = [], - reconfiguredSectionIdentifiers: Set = [] - ) { - self.sections = sections - self.reconfiguredItemIdentifiers = reconfiguredItemIdentifiers - self.reconfiguredSectionIdentifiers = reconfiguredSectionIdentifiers - } -} diff --git a/Libraries/CollectionUI/Sources/CollectionSwipeActions.swift b/Libraries/CollectionUI/Sources/CollectionSwipeActions.swift deleted file mode 100644 index 563b0ee7..00000000 --- a/Libraries/CollectionUI/Sources/CollectionSwipeActions.swift +++ /dev/null @@ -1,15 +0,0 @@ -import UIKit - -// UIKit swipe action을 적용할 방향 -public enum CollectionSwipeActionsEdge: Equatable, Sendable { - // cell의 좌측에서 우측 방향 swipe action - case leading - // cell의 우측에서 좌측 방향 swipe action - case trailing -} - -struct CollectionSwipeActions where ItemIdentifier: Hashable & Sendable { - let edge: CollectionSwipeActionsEdge - let allowsFullSwipe: Bool - let actions: (ItemIdentifier) -> [UIContextualAction] -} diff --git a/Libraries/CollectionUI/Sources/CollectionView+Events.swift b/Libraries/CollectionUI/Sources/CollectionView+Events.swift deleted file mode 100644 index 27b4fb6f..00000000 --- a/Libraries/CollectionUI/Sources/CollectionView+Events.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI -import UIKit - -public extension CollectionView { - // item 선택 시 전달할 동작 지정 - func onSelect(_ action: @escaping (ItemIdentifier) -> Void) -> Self { - var view = self - view.configuration.onSelect = action - return view - } - - // item이 표시되기 시작할 때 전달할 동작 지정 - func onWillDisplay(_ action: @escaping (ItemIdentifier) -> Void) -> Self { - var view = self - view.configuration.onWillDisplay = action - return view - } - - // item 선준비를 시작할 때 전달할 동작 지정 - func onPrefetch(_ action: @escaping ([ItemIdentifier]) -> Void) -> Self { - var view = self - view.configuration.onPrefetch = action - return view - } - - // item 선준비를 취소할 때 전달할 동작 지정 - func onCancelPrefetch(_ action: @escaping ([ItemIdentifier]) -> Void) -> Self { - var view = self - view.configuration.onCancelPrefetch = action - return view - } - - // scroll 위치가 바뀔 때 전달할 동작 지정 - func onScroll(_ action: @escaping (CGPoint) -> Void) -> Self { - var view = self - view.configuration.onScroll = action - return view - } -} diff --git a/Libraries/CollectionUI/Sources/CollectionView+Refresh.swift b/Libraries/CollectionUI/Sources/CollectionView+Refresh.swift deleted file mode 100644 index ea8387e1..00000000 --- a/Libraries/CollectionUI/Sources/CollectionView+Refresh.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -public extension CollectionView { - // UIKit 새로고침 완료를 기다릴 비동기 동작 지정 - func refreshable(_ action: @escaping () async -> Void) -> Self { - var view = self - view.configuration.refreshAction = action - return view - } -} diff --git a/Libraries/CollectionUI/Sources/CollectionView+SupplementaryViews.swift b/Libraries/CollectionUI/Sources/CollectionView+SupplementaryViews.swift deleted file mode 100644 index f50470d2..00000000 --- a/Libraries/CollectionUI/Sources/CollectionView+SupplementaryViews.swift +++ /dev/null @@ -1,13 +0,0 @@ -import SwiftUI -import UIKit - -public extension CollectionView { - // section 식별자로 supplementary view를 만들 동작 지정 - func supplementaryViews( - _ provider: @escaping (UICollectionView, String, IndexPath, SectionIdentifier) -> UICollectionReusableView? - ) -> Self { - var view = self - view.configuration.supplementaryViewProvider = provider - return view - } -} diff --git a/Libraries/CollectionUI/Sources/CollectionView+SwipeActions.swift b/Libraries/CollectionUI/Sources/CollectionView+SwipeActions.swift deleted file mode 100644 index d05839a3..00000000 --- a/Libraries/CollectionUI/Sources/CollectionView+SwipeActions.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftUI -import UIKit - -public extension CollectionView { - // item 식별자로 UIKit swipe action을 만들 동작 추가 - func swipeActions( - edge: CollectionSwipeActionsEdge = .trailing, - allowsFullSwipe: Bool = true, - actions: @escaping (ItemIdentifier) -> [UIContextualAction] - ) -> Self { - var view = self - view.configuration.swipeActions.append(.init( - edge: edge, - allowsFullSwipe: allowsFullSwipe, - actions: actions - )) - return view - } -} diff --git a/Libraries/CollectionUI/Sources/CollectionView.swift b/Libraries/CollectionUI/Sources/CollectionView.swift deleted file mode 100644 index 3aa71a7c..00000000 --- a/Libraries/CollectionUI/Sources/CollectionView.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// CollectionView.swift -// CollectionUI -// -// Created by opfic on 8/20/26. -// - -import SwiftUI - -// SwiftUI 화면과 UIKit collection controller 연결 -@MainActor -public struct CollectionView: UIViewControllerRepresentable -where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { - var configuration: CollectionViewConfiguration - - // 표시할 식별자 snapshot과 UIKit layout·cell provider 지정 - public init( - snapshot: CollectionRenderingSnapshot, - layoutProvider: @escaping (SectionIdentifier, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection, - cellProvider: @escaping (UICollectionView, IndexPath, ItemIdentifier) -> UICollectionViewCell? - ) { - configuration = CollectionViewConfiguration( - snapshot: snapshot, - layoutProvider: layoutProvider, - cellProvider: cellProvider - ) - } - - // UIKit collection controller 생성 - public func makeUIViewController(context: Context) -> UIViewController { - CollectionViewController(configuration: configuration) - } - - // 최신 rendering 입력과 provider의 UIKit controller 반영 - public func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - (uiViewController as? CollectionViewController)?.update(configuration: configuration) - } - - // UIKit controller의 delegate와 진행 중인 작업 정리 - public static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: ()) { - (uiViewController as? CollectionViewController)?.dismantle() - } -} diff --git a/Libraries/CollectionUI/Sources/CollectionViewConfiguration.swift b/Libraries/CollectionUI/Sources/CollectionViewConfiguration.swift deleted file mode 100644 index 60bc9bf2..00000000 --- a/Libraries/CollectionUI/Sources/CollectionViewConfiguration.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// CollectionViewConfiguration.swift -// CollectionUI -// -// Created by opfic on 8/20/26. -// - -import UIKit - -struct CollectionViewConfiguration -where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { - let snapshot: CollectionRenderingSnapshot - let layoutProvider: (SectionIdentifier, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection - let cellProvider: (UICollectionView, IndexPath, ItemIdentifier) -> UICollectionViewCell? - var onSelect: ((ItemIdentifier) -> Void)? - var onWillDisplay: ((ItemIdentifier) -> Void)? - var onPrefetch: (([ItemIdentifier]) -> Void)? - var onCancelPrefetch: (([ItemIdentifier]) -> Void)? - var onScroll: ((CGPoint) -> Void)? - var swipeActions: [CollectionSwipeActions] = [] - var refreshAction: (() async -> Void)? - var supplementaryViewProvider: - ((UICollectionView, String, IndexPath, SectionIdentifier) -> UICollectionReusableView?)? -} diff --git a/Libraries/CollectionUI/Sources/CollectionViewController.swift b/Libraries/CollectionUI/Sources/CollectionViewController.swift deleted file mode 100644 index 8a92ffcf..00000000 --- a/Libraries/CollectionUI/Sources/CollectionViewController.swift +++ /dev/null @@ -1,413 +0,0 @@ -// -// CollectionViewController.swift -// CollectionUI -// -// Created by opfic on 8/20/26. -// - -import UIKit - -@MainActor -final class CollectionViewController: UIViewController, - UICollectionViewDelegate, - UICollectionViewDataSourcePrefetching -where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { - private static var emptyCellReuseIdentifier: String { "CollectionViewEmptyCell" } - - private let collectionView: UICollectionView - private var configuration: CollectionViewConfiguration - private var dataSource: UICollectionViewDiffableDataSource! - private var appliedSnapshot: CollectionRenderingSnapshot? - private var prefetchedItemIdentifiers = Set() - private var refreshTask: Task? - - private struct ScrollAnchor { - let itemIdentifier: ItemIdentifier - let relativeOffset: CGPoint - } - - private struct PreservedState { - let selectedItemIdentifiers: [ItemIdentifier] - let scrollAnchor: ScrollAnchor? - let contentOffset: CGPoint - } - - init(configuration: CollectionViewConfiguration) { - self.configuration = configuration - collectionView = UICollectionView( - frame: .zero, - collectionViewLayout: UICollectionViewCompositionalLayout { _, _ in nil } - ) - super.init(nibName: nil, bundle: nil) - configureCollectionView() - configureDataSource() - configureLayout() - collectionView.delegate = self - collectionView.prefetchDataSource = self - updateRefreshControl() - applySnapshotIfNeeded() - } - - required init?(coder: NSCoder) { - nil - } - - override func loadView() { - view = collectionView - } - - func update(configuration: CollectionViewConfiguration) { - guard validates(configuration.snapshot) else { - return - } - self.configuration = configuration - collectionView.collectionViewLayout.invalidateLayout() - updateRefreshControl() - applySnapshotIfNeeded() - } - - func dismantle() { - refreshTask?.cancel() - refreshTask = nil - collectionView.delegate = nil - collectionView.prefetchDataSource = nil - collectionView.refreshControl = nil - dataSource.supplementaryViewProvider = nil - } - - func snapshot() -> NSDiffableDataSourceSnapshot { - dataSource.snapshot() - } - - private func configureCollectionView() { - collectionView.register( - CollectionViewEmptyCell.self, - forCellWithReuseIdentifier: Self.emptyCellReuseIdentifier - ) - } - - private func configureDataSource() { - dataSource = UICollectionViewDiffableDataSource( - collectionView: collectionView - ) { [weak self] collectionView, indexPath, itemIdentifier in - guard let cell = self?.configuration.cellProvider(collectionView, indexPath, itemIdentifier) else { - return self?.emptyCell(in: collectionView, for: indexPath, itemIdentifier: itemIdentifier) - } - return cell - } - dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in - guard - let self, - let sectionIdentifier = self.sectionIdentifier(at: indexPath.section) - else { return nil } - return self.configuration.supplementaryViewProvider?( - collectionView, - kind, - indexPath, - sectionIdentifier - ) - } - } - - private func configureLayout() { - collectionView.setCollectionViewLayout( - UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in - guard let sectionIdentifier = self?.sectionIdentifier(at: sectionIndex) else { - return nil - } - return self?.configuration.layoutProvider(sectionIdentifier, environment) - }, - animated: false - ) - } - - private func updateRefreshControl() { - guard configuration.refreshAction != nil else { - collectionView.refreshControl = nil - return - } - - guard collectionView.refreshControl == nil else { - return - } - let refreshControl = UIRefreshControl() - refreshControl.addAction(UIAction { [weak self] _ in - self?.refresh() - }, for: .valueChanged) - collectionView.refreshControl = refreshControl - } - - private func refresh() { - guard let action = configuration.refreshAction else { - collectionView.refreshControl?.endRefreshing() - return - } - refreshTask?.cancel() - refreshTask = Task { [weak self] in - await action() - guard !Task.isCancelled else { return } - self?.collectionView.refreshControl?.endRefreshing() - } - } - - private func applySnapshotIfNeeded() { - let snapshot = configuration.snapshot - let requiresStructuralUpdate = !hasSameStructure(snapshot, appliedSnapshot) - let hasReconfiguredItems = !snapshot.reconfiguredItemIdentifiers.isEmpty - let hasReconfiguredSections = !snapshot.reconfiguredSectionIdentifiers.isEmpty - guard requiresStructuralUpdate || hasReconfiguredItems || hasReconfiguredSections else { - return - } - - guard validates(snapshot) else { - return - } - - let diffableSnapshot = makeDiffableSnapshot(from: snapshot) - if appliedSnapshot == nil { - dataSource.applySnapshotUsingReloadData(diffableSnapshot) - } else { - let preservedState = makePreservedState() - dataSource.apply(diffableSnapshot, animatingDifferences: true) { [weak self] in - self?.restore(preservedState) - } - } - appliedSnapshot = CollectionRenderingSnapshot(sections: snapshot.sections) - } - - private func hasSameStructure( - _ lhs: CollectionRenderingSnapshot, - _ rhs: CollectionRenderingSnapshot? - ) -> Bool { - lhs.sections == rhs?.sections - } - - func makeDiffableSnapshot( - from renderingSnapshot: CollectionRenderingSnapshot - ) -> NSDiffableDataSourceSnapshot { - var snapshot = NSDiffableDataSourceSnapshot() - - for section in renderingSnapshot.sections { - snapshot.appendSections([section.identifier]) - snapshot.appendItems(section.itemIdentifiers, toSection: section.identifier) - } - - let identifiers = renderingSnapshot.reconfiguredItemIdentifiers.filter { - snapshot.indexOfItem($0) != nil - } - snapshot.reconfigureItems(Array(identifiers)) - let sectionIdentifiers = renderingSnapshot.reconfiguredSectionIdentifiers.filter { - snapshot.indexOfSection($0) != nil - } - snapshot.reloadSections(Array(sectionIdentifiers)) - return snapshot - } - - private func validates( - _ snapshot: CollectionRenderingSnapshot - ) -> Bool { - var sectionIdentifiers = Set() - var itemIdentifiers = Set() - - for section in snapshot.sections { - guard sectionIdentifiers.insert(section.identifier).inserted else { - assertionFailure("CollectionRenderingSnapshot contains a duplicated section identifier.") - return false - } - - for itemIdentifier in section.itemIdentifiers { - guard itemIdentifiers.insert(itemIdentifier).inserted else { - assertionFailure("CollectionRenderingSnapshot contains a duplicated item identifier.") - return false - } - } - } - return true - } - - private func sectionIdentifier(at index: Int) -> SectionIdentifier? { - let sections = appliedSnapshot?.sections ?? configuration.snapshot.sections - guard sections.indices.contains(index) else { - return nil - } - return sections[index].identifier - } - - private func makePreservedState() -> PreservedState { - let selectedItemIdentifiers = (collectionView.indexPathsForSelectedItems ?? []).compactMap { - dataSource.itemIdentifier(for: $0) - } - return PreservedState( - selectedItemIdentifiers: selectedItemIdentifiers, - scrollAnchor: makeScrollAnchor(), - contentOffset: collectionView.contentOffset - ) - } - - private func makeScrollAnchor() -> ScrollAnchor? { - collectionView.layoutIfNeeded() - let indexPaths = collectionView.indexPathsForVisibleItems - let indexPath = indexPaths.min { lhs, rhs in - let lhsOrigin = collectionView.layoutAttributesForItem(at: lhs)?.frame.origin ?? .zero - let rhsOrigin = collectionView.layoutAttributesForItem(at: rhs)?.frame.origin ?? .zero - return lhsOrigin.y == rhsOrigin.y ? lhsOrigin.x < rhsOrigin.x : lhsOrigin.y < rhsOrigin.y - } - guard - let indexPath, - let itemIdentifier = dataSource.itemIdentifier(for: indexPath), - let attributes = collectionView.layoutAttributesForItem(at: indexPath) - else { return nil } - return ScrollAnchor( - itemIdentifier: itemIdentifier, - relativeOffset: CGPoint( - x: attributes.frame.minX - collectionView.contentOffset.x, - y: attributes.frame.minY - collectionView.contentOffset.y - ) - ) - } - - private func restore(_ state: PreservedState) { - guard hasSameUserState(as: state) else { - return - } - collectionView.layoutIfNeeded() - restoreSelection(state.selectedItemIdentifiers) - restoreScrollPosition(state.scrollAnchor) - } - - private func hasSameUserState(as state: PreservedState) -> Bool { - let selectedItemIdentifiers = (collectionView.indexPathsForSelectedItems ?? []).compactMap { - dataSource.itemIdentifier(for: $0) - } - return selectedItemIdentifiers == state.selectedItemIdentifiers - && collectionView.contentOffset == state.contentOffset - } - - private func restoreSelection(_ itemIdentifiers: [ItemIdentifier]) { - for itemIdentifier in itemIdentifiers { - guard let indexPath = dataSource.indexPath(for: itemIdentifier) else { continue } - collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) - } - } - - private func restoreScrollPosition(_ anchor: ScrollAnchor?) { - guard - let anchor, - let indexPath = dataSource.indexPath(for: anchor.itemIdentifier), - let attributes = collectionView.layoutAttributesForItem(at: indexPath) - else { - clampContentOffset() - return - } - let adjustedInset = collectionView.adjustedContentInset - let maximumOffset = CGPoint( - x: max( - -adjustedInset.left, - collectionView.contentSize.width - collectionView.bounds.width + adjustedInset.right - ), - y: max( - -adjustedInset.top, - collectionView.contentSize.height - collectionView.bounds.height + adjustedInset.bottom - ) - ) - let offset = CGPoint( - x: min(maximumOffset.x, max(-adjustedInset.left, attributes.frame.minX - anchor.relativeOffset.x)), - y: min(maximumOffset.y, max(-adjustedInset.top, attributes.frame.minY - anchor.relativeOffset.y)) - ) - collectionView.setContentOffset(offset, animated: false) - } - - private func clampContentOffset() { - let adjustedInset = collectionView.adjustedContentInset - let maximumOffset = CGPoint( - x: max( - -adjustedInset.left, - collectionView.contentSize.width - collectionView.bounds.width + adjustedInset.right - ), - y: max( - -adjustedInset.top, - collectionView.contentSize.height - collectionView.bounds.height + adjustedInset.bottom - ) - ) - let offset = CGPoint( - x: min(maximumOffset.x, max(-adjustedInset.left, collectionView.contentOffset.x)), - y: min(maximumOffset.y, max(-adjustedInset.top, collectionView.contentOffset.y)) - ) - collectionView.setContentOffset(offset, animated: false) - } - - private func emptyCell( - in collectionView: UICollectionView, - for indexPath: IndexPath, - itemIdentifier: ItemIdentifier - ) -> UICollectionViewCell { - #if DEBUG - assertionFailure("CollectionUI cell provider did not return a cell for \(itemIdentifier).") - #endif - return collectionView.dequeueReusableCell( - withReuseIdentifier: Self.emptyCellReuseIdentifier, - for: indexPath - ) - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else { return } - configuration.onSelect?(itemIdentifier) - } - - func collectionView( - _ collectionView: UICollectionView, - willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath - ) { - guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else { return } - prefetchedItemIdentifiers.remove(itemIdentifier) - configuration.onWillDisplay?(itemIdentifier) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - configuration.onScroll?(scrollView.contentOffset) - } - - func collectionView( - _ collectionView: UICollectionView, - leadingSwipeActionsConfigurationForItemAt indexPath: IndexPath - ) -> UISwipeActionsConfiguration? { - swipeActionsConfiguration(for: indexPath, edge: .leading) - } - - func collectionView( - _ collectionView: UICollectionView, - trailingSwipeActionsConfigurationForItemAt indexPath: IndexPath - ) -> UISwipeActionsConfiguration? { - swipeActionsConfiguration(for: indexPath, edge: .trailing) - } - func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { - let identifiers = indexPaths.compactMap(dataSource.itemIdentifier).filter { - prefetchedItemIdentifiers.insert($0).inserted - } - guard !identifiers.isEmpty else { return } - configuration.onPrefetch?(identifiers) - } - - func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { - let identifiers = indexPaths.compactMap(dataSource.itemIdentifier).filter { - prefetchedItemIdentifiers.remove($0) != nil - } - guard !identifiers.isEmpty else { return } - configuration.onCancelPrefetch?(identifiers) - } - - private func swipeActionsConfiguration( - for indexPath: IndexPath, - edge: CollectionSwipeActionsEdge - ) -> UISwipeActionsConfiguration? { - guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else { return nil } - let configurations = configuration.swipeActions.filter { $0.edge == edge } - let actions = configurations.flatMap { $0.actions(itemIdentifier) } - guard !actions.isEmpty else { return nil } - let configuration = UISwipeActionsConfiguration(actions: actions) - configuration.performsFirstActionWithFullSwipe = configurations.allSatisfy(\.allowsFullSwipe) - return configuration - } -} diff --git a/Libraries/CollectionUI/Sources/CollectionViewEmptyCell.swift b/Libraries/CollectionUI/Sources/CollectionViewEmptyCell.swift deleted file mode 100644 index 3e064aa1..00000000 --- a/Libraries/CollectionUI/Sources/CollectionViewEmptyCell.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// CollectionViewEmptyCell.swift -// CollectionUI -// -// Created by opfic on 8/20/26. -// - -import UIKit - -final class CollectionViewEmptyCell: UICollectionViewCell { - override func prepareForReuse() { - super.prepareForReuse() - isUserInteractionEnabled = false - } -} diff --git a/Libraries/CollectionUI/Tests/.swiftlint.yml b/Libraries/CollectionUI/Tests/.swiftlint.yml deleted file mode 100644 index b2c5c38d..00000000 --- a/Libraries/CollectionUI/Tests/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint-tests.yml diff --git a/Libraries/CollectionUI/Tests/CollectionRenderingSnapshotTests.swift b/Libraries/CollectionUI/Tests/CollectionRenderingSnapshotTests.swift deleted file mode 100644 index 6eeafae0..00000000 --- a/Libraries/CollectionUI/Tests/CollectionRenderingSnapshotTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// CollectionRenderingSnapshotTests.swift -// CollectionUITests -// -// Created by opfic on 8/20/26. -// - -import Testing -@testable import CollectionUI - -struct CollectionRenderingSnapshotTests { - @Test("section과 item 식별자의 표시 순서를 유지한다") - func preservesSectionAndItemIdentifierOrder() { - let snapshot = CollectionRenderingSnapshot( - sections: [ - .init(identifier: "pinned", itemIdentifiers: ["todo-1", "todo-2"]), - .init(identifier: "default", itemIdentifiers: ["todo-3"]) - ] - ) - - #expect(snapshot.sections.map(\.identifier) == ["pinned", "default"]) - #expect(snapshot.sections[0].itemIdentifiers == ["todo-1", "todo-2"]) - #expect(snapshot.sections[1].itemIdentifiers == ["todo-3"]) - } - - @Test("내용이 변경된 item 식별자를 별도로 보관한다") - func storesReconfiguredItemIdentifiers() { - let snapshot = CollectionRenderingSnapshot( - sections: [ - .init(identifier: "default", itemIdentifiers: ["todo-1", "todo-2"]) - ], - reconfiguredItemIdentifiers: ["todo-2"] - ) - - #expect(snapshot.reconfiguredItemIdentifiers == ["todo-2"]) - } - - @Test("동일한 rendering snapshot은 같은 값으로 비교한다") - func comparesEqualRenderingSnapshots() { - let section = CollectionRenderingSnapshot.Section( - identifier: "default", - itemIdentifiers: ["todo-1"] - ) - let lhs = CollectionRenderingSnapshot(sections: [section]) - let rhs = CollectionRenderingSnapshot(sections: [section]) - - #expect(lhs == rhs) - } - - @Test("내용이 변경된 section 식별자를 별도로 보관한다") - func storesReconfiguredSectionIdentifiers() { - let snapshot = CollectionRenderingSnapshot( - sections: [.init(identifier: "default", itemIdentifiers: [])], - reconfiguredSectionIdentifiers: ["default"] - ) - - #expect(snapshot.reconfiguredSectionIdentifiers == ["default"]) - } -} diff --git a/Libraries/CollectionUI/Tests/CollectionViewControllerInteractionTests.swift b/Libraries/CollectionUI/Tests/CollectionViewControllerInteractionTests.swift deleted file mode 100644 index bcc78b69..00000000 --- a/Libraries/CollectionUI/Tests/CollectionViewControllerInteractionTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// CollectionViewControllerInteractionTests.swift -// CollectionUITests -// -// Created by opfic on 8/20/26. -// - -import Testing -import UIKit -@testable import CollectionUI - -@MainActor -struct CollectionViewControllerInteractionTests { - @Test("선택과 표시 callback은 item 식별자를 전달한다") - func forwardsSelectionAndDisplayIdentifiers() { - var selectedIdentifier: String? - var displayedIdentifier: String? - let controller = controller( - onSelect: { selectedIdentifier = $0 }, - onWillDisplay: { displayedIdentifier = $0 } - ) - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - let indexPath = IndexPath(item: 0, section: 0) - - controller.collectionView(collectionView, didSelectItemAt: indexPath) - controller.collectionView(collectionView, willDisplay: UICollectionViewCell(), forItemAt: indexPath) - - #expect(selectedIdentifier == "todo-1") - #expect(displayedIdentifier == "todo-1") - } - - @Test("prefetch와 취소 callback은 중복 item을 한 번만 전달한다") - func deduplicatesPrefetchAndCancellationIdentifiers() { - var prefetchedIdentifiers = [[String]]() - var cancelledIdentifiers = [[String]]() - let controller = controller( - onPrefetch: { prefetchedIdentifiers.append($0) }, - onCancelPrefetch: { cancelledIdentifiers.append($0) } - ) - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) - let indexPath = IndexPath(item: 0, section: 0) - - controller.collectionView(collectionView, prefetchItemsAt: [indexPath, indexPath]) - controller.collectionView(collectionView, cancelPrefetchingForItemsAt: [indexPath, indexPath]) - - #expect(prefetchedIdentifiers == [["todo-1"]]) - #expect(cancelledIdentifiers == [["todo-1"]]) - } - - private func controller( - onSelect: ((String) -> Void)? = nil, - onWillDisplay: ((String) -> Void)? = nil, - onPrefetch: (([String]) -> Void)? = nil, - onCancelPrefetch: (([String]) -> Void)? = nil - ) -> CollectionViewController { - var configuration = CollectionViewConfiguration( - snapshot: CollectionRenderingSnapshot(sections: [ - .init(identifier: "default", itemIdentifiers: ["todo-1"]) - ]), - layoutProvider: { _, _ in - NSCollectionLayoutSection(group: .horizontal( - layoutSize: NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44) - ), - subitems: [] - )) - }, - cellProvider: { _, _, _ in UICollectionViewCell() } - ) - configuration.onSelect = onSelect - configuration.onWillDisplay = onWillDisplay - configuration.onPrefetch = onPrefetch - configuration.onCancelPrefetch = onCancelPrefetch - return CollectionViewController(configuration: configuration) - } -} diff --git a/Libraries/CollectionUI/Tests/CollectionViewControllerSnapshotTests.swift b/Libraries/CollectionUI/Tests/CollectionViewControllerSnapshotTests.swift deleted file mode 100644 index 850dda04..00000000 --- a/Libraries/CollectionUI/Tests/CollectionViewControllerSnapshotTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// CollectionViewControllerSnapshotTests.swift -// CollectionUITests -// -// Created by opfic on 8/20/26. -// - -import Testing -import UIKit -@testable import CollectionUI - -@MainActor -struct CollectionViewControllerSnapshotTests { - @Test("controller는 section과 item을 diffable snapshot으로 적용한다") - func appliesRenderingSnapshot() { - let controller = CollectionViewController(configuration: configuration( - snapshot: .init(sections: [ - .init(identifier: "default", itemIdentifiers: ["todo-1", "todo-2"]) - ]) - )) - - let snapshot = controller.snapshot() - - #expect(snapshot.sectionIdentifiers == ["default"]) - #expect(snapshot.itemIdentifiers(inSection: "default") == ["todo-1", "todo-2"]) - } - - @Test("다시 구성할 item만 diffable snapshot에 표시한다") - func reconfiguresSpecifiedItems() { - let renderingSnapshot = CollectionRenderingSnapshot( - sections: [ - .init(identifier: "default", itemIdentifiers: ["todo-1", "todo-2"]) - ], - reconfiguredItemIdentifiers: ["todo-2"] - ) - let controller = CollectionViewController(configuration: configuration(snapshot: renderingSnapshot)) - - #expect(controller.makeDiffableSnapshot(from: renderingSnapshot).reconfiguredItemIdentifiers == ["todo-2"]) - } - - private func configuration( - snapshot: CollectionRenderingSnapshot - ) -> CollectionViewConfiguration { - CollectionViewConfiguration( - snapshot: snapshot, - layoutProvider: { _, _ in - NSCollectionLayoutSection(group: .horizontal( - layoutSize: NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44) - ), - subitems: [] - )) - }, - cellProvider: { collectionView, indexPath, _ in - collectionView.dequeueReusableCell( - withReuseIdentifier: "cell", - for: indexPath - ) - } - ) - } -} diff --git a/Workspace.swift b/Workspace.swift index 95aadfa0..90e43d59 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -11,7 +11,6 @@ let workspace = Workspace( "Application/Persistence", "Application/Presentation", "Application/Widget", - "Libraries/CollectionUI", "Libraries/MarkdownRenderer", "Libraries/ThirdParty", "Widget/WidgetCore", diff --git a/docs/graph.png b/docs/graph.png index 3cc8ac90..ef3c6a05 100644 Binary files a/docs/graph.png and b/docs/graph.png differ