From a7512afba7dff4d10c26cb42b62a461b02530b96 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Sat, 15 Aug 2026 20:26:51 +0200 Subject: [PATCH 1/7] Adopt default main actor isolation and drop CocoaPods Set `defaultIsolation` to `MainActor` and the Swift 6 language mode for both targets, which makes the whole UIKit-facing API main actor-isolated without a single annotation in the library sources. Platforms move to iOS 15 / tvOS 15 to match FTAPIKit, which makes the `@available(iOS 13.0, tvOS 13.0, *)` annotations redundant. CocoaPods support is removed in favour of the Swift package, so the `#if SWIFT_PACKAGE` guards around `import CellKit` go away too. The Example app is updated to the Swift 6 language mode with default main actor isolation and approachable concurrency, matching how apps consuming CellKit are configured. CI is rewritten to match FTAPIKit: no `pod lib lint`, current runner actions, and a build of both library schemes plus the Example app. The two long-standing SwiftLint violations in DataSource.swift are fixed so `swiftlint --strict` passes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 28 ++++++ .github/workflows/test.yml | 23 ----- CellKit.podspec | 30 ------- Example/Example.xcodeproj/project.pbxproj | 42 ++++++--- Example/Sources/Supporting/AppDelegate.swift | 2 +- .../AndroidCell/DeviceAndroidCellModel.swift | 2 +- .../IOSCell/DeviceiOSCellModel.swift | 2 +- .../Table Cells/NibTableViewCell.swift | 2 +- .../Table Headers/PhonesHeaderModel.swift | 2 +- Example/Sources/ViewController.swift | 4 +- Gemfile | 5 -- Gemfile.lock | 90 ------------------- Package.swift | 17 +++- README.md | 42 ++++++--- Sources/CellKit/DataSource.swift | 4 +- .../CollectionSupplementaryViewModel.swift | 1 - .../LazyCellProvider.swift | 1 - .../LazySupplementaryViewProvider.swift | 1 - .../SupplementaryElementKind.swift | 1 - .../DifferentiableCellModel.swift | 4 +- .../DifferentiableCellModelDataSource.swift | 4 +- .../DifferentiableCellModelSection.swift | 4 +- 22 files changed, 113 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 CellKit.podspec delete mode 100644 Gemfile delete mode 100644 Gemfile.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..87f9895 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +jobs: + test: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v6 + - name: Install SwiftLint + run: brew install swiftlint + - name: Lint + run: | + swiftlint --strict + - name: Build + run: | + xcodebuild -scheme CellKit -destination 'generic/platform=iOS' build + xcodebuild -scheme DiffableCellKit -destination 'generic/platform=iOS' build + - name: Build example app + run: | + xcodebuild -project Example/Example.xcodeproj -scheme Example -destination 'generic/platform=iOS Simulator' -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 723488b..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Test - -on: - - pull_request - -jobs: - test: - runs-on: macos-latest - - steps: - - uses: actions/checkout@v2 - - name: Lint - run: | - brew install swiftlint - swiftlint --strict - - name: Pod lib lint - run: | - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec pod lib lint - - name: Build example app - run: | - xcodebuild -project Example/Example.xcodeproj -scheme Example -destination 'platform=iOS Simulator,name=iPhone 8' -sdk iphonesimulator test diff --git a/CellKit.podspec b/CellKit.podspec deleted file mode 100644 index cf1dee0..0000000 --- a/CellKit.podspec +++ /dev/null @@ -1,30 +0,0 @@ -Pod::Spec.new do |s| - s.name = "CellKit" - s.version = "0.8.1" - s.summary = "Table View and Collection View data source wrapper" - s.description = <<-DESC - Generic abstraction over table/collection data source - with automatic cell registration of - cell models and automatic animations using diffs. - DESC - s.homepage = "https://github.com/futuredapp/CellKit" - s.license = { type: "MIT", file: "LICENSE" } - s.author = { "Matěj K. Jirásek": "matej.jirasek@futured.app", "Petr Zvoníček": "zvonicek@gmail.com" } - s.social_media_url = "https://twitter.com/Futuredapps" - s.ios.deployment_target = "9.0" - s.tvos.deployment_target = "9.0" - s.swift_versions = ["4.2", "5.0", "5.1"] - s.source = { git: "https://github.com/futuredapp/CellKit.git", tag: s.version.to_s } - s.frameworks = ["Foundation", "UIKit"] - s.default_subspec = "Core" - - s.subspec "Core" do |ss| - ss.source_files = "Sources/CellKit/*" - end - - s.subspec "Diffable" do |ss| - ss.dependency "CellKit/Core" - ss.source_files = "Sources/DiffableCellKit/*" - ss.dependency "DifferenceKit", "~> 1.0" - end -end diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index b1b2359..b41fd93 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -409,12 +409,15 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -463,10 +466,13 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - SWIFT_VERSION = 4.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -490,7 +496,7 @@ DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = "$(SRCROOT)/Sources/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -499,7 +505,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -520,7 +529,7 @@ DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = "$(SRCROOT)/Sources/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -530,7 +539,10 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -547,7 +559,7 @@ CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Tests/ExampleUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -558,7 +570,10 @@ PRODUCT_BUNDLE_IDENTIFIER = com.thefuntasty.ExampleUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Example; }; @@ -576,7 +591,7 @@ CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Tests/ExampleUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -587,7 +602,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Example; }; diff --git a/Example/Sources/Supporting/AppDelegate.swift b/Example/Sources/Supporting/AppDelegate.swift index cea377f..8541c58 100644 --- a/Example/Sources/Supporting/AppDelegate.swift +++ b/Example/Sources/Supporting/AppDelegate.swift @@ -1,6 +1,6 @@ import UIKit -@UIApplicationMain +@main final class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? diff --git a/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift b/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift index b831374..570f910 100644 --- a/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift +++ b/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift @@ -2,7 +2,7 @@ import CellKit import DiffableCellKit import UIKit -struct DeviceAndroidCellModel: CellConvertible, DifferentiableCellModel, DeletableCellModel { +struct DeviceAndroidCellModel: @MainActor CellConvertible, @MainActor DifferentiableCellModel, DeletableCellModel { var domainIdentifier: Int { name.hashValue diff --git a/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift b/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift index b83324c..088327f 100644 --- a/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift +++ b/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift @@ -2,7 +2,7 @@ import CellKit import DiffableCellKit import UIKit -struct DeviceiOSCellModel: CellConvertible, DifferentiableCellModel, DeletableCellModel { +struct DeviceiOSCellModel: @MainActor CellConvertible, @MainActor DifferentiableCellModel, DeletableCellModel { var domainIdentifier: Int { name.hashValue diff --git a/Example/Sources/Table Cells/NibTableViewCell.swift b/Example/Sources/Table Cells/NibTableViewCell.swift index 041f8ea..8ea5a4c 100644 --- a/Example/Sources/Table Cells/NibTableViewCell.swift +++ b/Example/Sources/Table Cells/NibTableViewCell.swift @@ -2,7 +2,7 @@ import CellKit import DiffableCellKit import UIKit -struct NibTableViewCellModel: ReusableCellConvertible, DifferentiableCellModel { +struct NibTableViewCellModel: @MainActor ReusableCellConvertible, @MainActor DifferentiableCellModel { var domainIdentifier: Int { text.hashValue diff --git a/Example/Sources/Table Headers/PhonesHeaderModel.swift b/Example/Sources/Table Headers/PhonesHeaderModel.swift index 5cb32d2..e2093c8 100644 --- a/Example/Sources/Table Headers/PhonesHeaderModel.swift +++ b/Example/Sources/Table Headers/PhonesHeaderModel.swift @@ -6,7 +6,7 @@ struct PhonesHeaderModel { let title: String } -extension PhonesHeaderModel: SupplementaryViewModel, CellConvertible { +extension PhonesHeaderModel: @MainActor SupplementaryViewModel, @MainActor CellConvertible { typealias Cell = PhonesHeader var height: Double { diff --git a/Example/Sources/ViewController.swift b/Example/Sources/ViewController.swift index c29c242..4182ced 100644 --- a/Example/Sources/ViewController.swift +++ b/Example/Sources/ViewController.swift @@ -4,8 +4,8 @@ import UIKit enum Constants { - static var iPhones = ["iPhone", "iPhone 3G", "iPhone 3GS", "iPhone 4", "iPhone 4S", "iPhone 5", "iPhone 5s", "iPhone 6", "iPhone 6s", "iPhone SE", "iPhone 7", "iPhone 8", "iPhone X"] - static var androids = ["Samsung Galaxy Note", "OnePlus 2", "Nexus 6", "Huawei P9", "Kindle Fire", "Samsung Galaxy S6", "Nexus 5"] + static let iPhones = ["iPhone", "iPhone 3G", "iPhone 3GS", "iPhone 4", "iPhone 4S", "iPhone 5", "iPhone 5s", "iPhone 6", "iPhone 6s", "iPhone SE", "iPhone 7", "iPhone 8", "iPhone X"] + static let androids = ["Samsung Galaxy Note", "OnePlus 2", "Nexus 6", "Huawei P9", "Kindle Fire", "Samsung Galaxy S6", "Nexus 5"] } final class ViewController: UITableViewController { diff --git a/Gemfile b/Gemfile deleted file mode 100644 index ef3f1b5..0000000 --- a/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -gem "cocoapods", "~> 1.9" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 6730098..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,90 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.2) - activesupport (4.2.11.3) - i18n (~> 0.7) - minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) - tzinfo (~> 1.1) - algoliasearch (1.27.3) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - claide (1.0.3) - cocoapods (1.9.3) - activesupport (>= 4.0.2, < 5) - claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.9.3) - cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.2.2, < 2.0) - cocoapods-plugins (>= 1.0.0, < 2.0) - cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-stats (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) - cocoapods-try (>= 1.1.0, < 2.0) - colored2 (~> 3.1) - escape (~> 0.0.4) - fourflusher (>= 2.3.0, < 3.0) - gh_inspector (~> 1.0) - molinillo (~> 0.6.6) - nap (~> 1.0) - ruby-macho (~> 1.4) - xcodeproj (>= 1.14.0, < 2.0) - cocoapods-core (1.9.3) - activesupport (>= 4.0.2, < 6) - algoliasearch (~> 1.0) - concurrent-ruby (~> 1.1) - fuzzy_match (~> 2.0.4) - nap (~> 1.0) - netrc (~> 0.11) - typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.4) - cocoapods-downloader (1.3.0) - cocoapods-plugins (1.0.0) - nap - cocoapods-search (1.0.0) - cocoapods-stats (1.1.0) - cocoapods-trunk (1.5.0) - nap (>= 0.8, < 2.0) - netrc (~> 0.11) - cocoapods-try (1.2.0) - colored2 (3.1.2) - concurrent-ruby (1.1.6) - escape (0.0.4) - ethon (0.12.0) - ffi (>= 1.3.0) - ffi (1.13.1) - fourflusher (2.3.1) - fuzzy_match (2.0.4) - gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (0.9.5) - concurrent-ruby (~> 1.0) - json (2.3.1) - minitest (5.14.1) - molinillo (0.6.6) - nanaimo (0.2.6) - nap (1.1.0) - netrc (0.11.0) - ruby-macho (1.4.0) - thread_safe (0.3.6) - typhoeus (1.4.0) - ethon (>= 0.9.0) - tzinfo (1.2.7) - thread_safe (~> 0.1) - xcodeproj (1.17.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.2.6) - -PLATFORMS - ruby - -DEPENDENCIES - cocoapods (~> 1.9) - -BUNDLED WITH - 2.1.4 diff --git a/Package.swift b/Package.swift index bc0094b..82a036e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,17 @@ -// swift-tools-version:5.1 +// swift-tools-version:6.2 import PackageDescription +let swiftSettings: [SwiftSetting] = [ + .swiftLanguageMode(.v6), + .defaultIsolation(MainActor.self), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances") +] + let package = Package( name: "CellKit", - platforms: [.iOS(.v9), .tvOS(.v9)], + platforms: [.iOS(.v15), .tvOS(.v15)], products: [ .library( name: "CellKit", @@ -19,9 +26,11 @@ let package = Package( targets: [ .target( name: "CellKit", - dependencies: []), + dependencies: [], + swiftSettings: swiftSettings), .target( name: "DiffableCellKit", - dependencies: ["CellKit", "DifferenceKit"]) + dependencies: ["CellKit", "DifferenceKit"], + swiftSettings: swiftSettings) ] ) diff --git a/README.md b/README.md index a42615b..b25f525 100644 --- a/README.md +++ b/README.md @@ -2,37 +2,55 @@ # CellKit -![Cocoapods](https://img.shields.io/cocoapods/v/CellKit.svg) -![Cocoapods platforms](https://img.shields.io/cocoapods/p/CellKit.svg) -![License](https://img.shields.io/cocoapods/l/CellKit.svg) +![Swift 6](https://img.shields.io/badge/Swift-6-orange.svg) +![Platforms](https://img.shields.io/badge/platform-iOS%20%7C%20tvOS-lightgrey.svg) +![License](https://img.shields.io/github/license/futuredapp/CellKit.svg) CellKit is a Swift package that streamlines the workflow with cells in UITableView and UICollectionView. No more registering cell classes or XIBs, no more dequeueing cells and setting its view models. CellKit handles this all. +## Requirements + +- iOS 15+ / tvOS 15+ +- Xcode 26+ (Swift 6.2 toolchain) + ## Installation ### Swift Package -Add following line to your swift package dependencies, or in Xcode, go to `File -> Swift Packages -> Add Package Dependency` and type in URL address of this repository. +Add following line to your swift package dependencies, or in Xcode, go to `File -> Add Package Dependencies` and type in URL address of this repository. ```swift -.package(url: "https://github.com/futuredapp/CellKit", from: "0.8.1") +.package(url: "https://github.com/futuredapp/CellKit", from: "1.0.0") ``` Optionally you can add `DiffableCellKit`. -### CocoaPods +## Concurrency + +CellKit is compiled in the Swift 6 language mode with `defaultIsolation` set to `MainActor`, so its entire API is main actor-isolated. Because CellKit is a `UITableView`/`UICollectionView` data source layer, every one of its protocols is only ever exercised on the main thread anyway. -Add following line to your `Podfile` and then run `pod install`: +**If your module uses the default `nonisolated` isolation**, conformances need no annotations at all: -```ruby -pod 'CellKit', '~> 0.8' +```swift +struct DeviceCellModel: ReusableCellConvertible, DifferentiableCellModel { + typealias Cell = DeviceCell + // ... +} ``` -Optionally you can add `DiffableCellKit` subspec: -```ruby -pod 'CellKit', '~> 0.8', subspecs: ['Diffable'] +**If your module is itself main actor by default** (`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`), isolate the conformance explicitly: + +```swift +struct DeviceCellModel: @MainActor ReusableCellConvertible, @MainActor DifferentiableCellModel { + typealias Cell = DeviceCell + // ... +} ``` +The second form is required because conformance isolation is not yet inferred through inherited protocol requirements, even with `InferIsolatedConformances` enabled — see [SE-0470](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0470-isolated-conformances.md). It is needed whether or not CellKit itself is main actor-isolated, so this is not new. The `Example` app is set up this way. + +Subclasses of `CellModelDataSource`, `AbstractDataSource` and `DifferentiableCellModelDataSource` are main actor-isolated, and so are your subclasses of them. + ## Usage CellKit provides a data source and a section model which you fill with your cells, headers and footer models. All you're left to do is to define your cell view and your cell model with protocol conformance to CellConvertible and CellConfigurable and CellKit will handle the rest. diff --git a/Sources/CellKit/DataSource.swift b/Sources/CellKit/DataSource.swift index e897be7..96a810b 100644 --- a/Sources/CellKit/DataSource.swift +++ b/Sources/CellKit/DataSource.swift @@ -152,7 +152,7 @@ extension AbstractDataSource: UITableViewDataSource { public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { let height = header(in: section)?.height - return height.flatMap { CGFloat($0) } ?? CGFloat.leastNonzeroMagnitude + return height.flatMap { CGFloat($0) } ?? CGFloat.leastNonzeroMagnitude } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { @@ -164,7 +164,7 @@ extension AbstractDataSource: UITableViewDataSource { public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { let height = footer(in: section)?.height - return height.flatMap { CGFloat($0) } ?? CGFloat.leastNonzeroMagnitude + return height.flatMap { CGFloat($0) } ?? CGFloat.leastNonzeroMagnitude } } diff --git a/Sources/CellKit/DiffableDataSources/CollectionSupplementaryViewModel.swift b/Sources/CellKit/DiffableDataSources/CollectionSupplementaryViewModel.swift index 261f90d..956d9f7 100644 --- a/Sources/CellKit/DiffableDataSources/CollectionSupplementaryViewModel.swift +++ b/Sources/CellKit/DiffableDataSources/CollectionSupplementaryViewModel.swift @@ -1,4 +1,3 @@ -@available(iOS 13.0, tvOS 13.0, *) public protocol CollectionSupplementaryViewModel: ReusableView { var kind: SupplementaryElementKind { get } diff --git a/Sources/CellKit/DiffableDataSources/LazyCellProvider.swift b/Sources/CellKit/DiffableDataSources/LazyCellProvider.swift index 0c3da50..42e91ca 100644 --- a/Sources/CellKit/DiffableDataSources/LazyCellProvider.swift +++ b/Sources/CellKit/DiffableDataSources/LazyCellProvider.swift @@ -1,6 +1,5 @@ import UIKit -@available(iOS 13.0, tvOS 13.0, *) public final class LazyCellProvider { private var registeredIdentifiers: Set diff --git a/Sources/CellKit/DiffableDataSources/LazySupplementaryViewProvider.swift b/Sources/CellKit/DiffableDataSources/LazySupplementaryViewProvider.swift index fbdc256..ee31004 100644 --- a/Sources/CellKit/DiffableDataSources/LazySupplementaryViewProvider.swift +++ b/Sources/CellKit/DiffableDataSources/LazySupplementaryViewProvider.swift @@ -1,6 +1,5 @@ import UIKit -@available(iOS 13.0, tvOS 13.0, *) public final class LazySupplementaryViewProvider { private var registeredIdentifiers: [String: Set] diff --git a/Sources/CellKit/DiffableDataSources/SupplementaryElementKind.swift b/Sources/CellKit/DiffableDataSources/SupplementaryElementKind.swift index ea06d69..96c50e5 100644 --- a/Sources/CellKit/DiffableDataSources/SupplementaryElementKind.swift +++ b/Sources/CellKit/DiffableDataSources/SupplementaryElementKind.swift @@ -1,6 +1,5 @@ import UIKit -@available(iOS 13.0, tvOS 13.0, *) public enum SupplementaryElementKind: RawRepresentable { case header case footer diff --git a/Sources/DiffableCellKit/DifferentiableCellModel.swift b/Sources/DiffableCellKit/DifferentiableCellModel.swift index fb8e836..0128973 100644 --- a/Sources/DiffableCellKit/DifferentiableCellModel.swift +++ b/Sources/DiffableCellKit/DifferentiableCellModel.swift @@ -1,7 +1,5 @@ -import DifferenceKit -#if SWIFT_PACKAGE import CellKit -#endif +import DifferenceKit /// Support for determining whether cell model belongs to a certain cell, whether cell should be inserted, removed, updated or moved. public protocol DifferentiableCellModel: CellModel { diff --git a/Sources/DiffableCellKit/DifferentiableCellModelDataSource.swift b/Sources/DiffableCellKit/DifferentiableCellModelDataSource.swift index 27d8ab5..a91ba9c 100644 --- a/Sources/DiffableCellKit/DifferentiableCellModelDataSource.swift +++ b/Sources/DiffableCellKit/DifferentiableCellModelDataSource.swift @@ -1,8 +1,6 @@ +import CellKit import DifferenceKit import UIKit -#if SWIFT_PACKAGE -import CellKit -#endif open class DifferentiableCellModelDataSource: AbstractDataSource, DataSource { diff --git a/Sources/DiffableCellKit/DifferentiableCellModelSection.swift b/Sources/DiffableCellKit/DifferentiableCellModelSection.swift index 5a5488c..a9d7ec4 100644 --- a/Sources/DiffableCellKit/DifferentiableCellModelSection.swift +++ b/Sources/DiffableCellKit/DifferentiableCellModelSection.swift @@ -1,7 +1,5 @@ -import DifferenceKit -#if SWIFT_PACKAGE import CellKit -#endif +import DifferenceKit public typealias DifferentiableCellModelSection = GenericCellModelSection From 4b1b18ddb71ce00587e2a6025f9cfb767d8e4c4f Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Sat, 15 Aug 2026 21:39:56 +0200 Subject: [PATCH 2/7] Use Approachable Concurrency setting in the Example Replace the two individual SWIFT_UPCOMING_FEATURE_* settings with SWIFT_APPROACHABLE_CONCURRENCY, which is what our projects actually set and which resolves to the same upcoming features in the Swift 6 language mode. Clarify in the README why a main actor-isolated consumer still needs `@MainActor` on each conformance: conformance isolation is not inferred for conformances to protocols that are themselves main actor-isolated, and InferIsolatedConformances does not cover that case. Co-Authored-By: Claude Opus 5 (1M context) --- Example/Example.xcodeproj/project.pbxproj | 18 ++++++------------ README.md | 6 ++++-- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index b41fd93..8081a6f 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -415,8 +415,7 @@ SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; - SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; @@ -470,8 +469,7 @@ MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; - SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -506,8 +504,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; - SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -540,8 +537,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; - SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -571,8 +567,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; - SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Example; @@ -603,8 +598,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_UPCOMING_FEATURE_INFER_ISOLATED_CONFORMANCES = YES; - SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Example; diff --git a/README.md b/README.md index b25f525..0450fb1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ struct DeviceCellModel: ReusableCellConvertible, DifferentiableCellModel { } ``` -**If your module is itself main actor by default** (`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`), isolate the conformance explicitly: +**If your module is itself main actor by default** (`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`), isolate each conformance explicitly: ```swift struct DeviceCellModel: @MainActor ReusableCellConvertible, @MainActor DifferentiableCellModel { @@ -47,7 +47,9 @@ struct DeviceCellModel: @MainActor ReusableCellConvertible, @MainActor Different } ``` -The second form is required because conformance isolation is not yet inferred through inherited protocol requirements, even with `InferIsolatedConformances` enabled — see [SE-0470](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0470-isolated-conformances.md). It is needed whether or not CellKit itself is main actor-isolated, so this is not new. The `Example` app is set up this way. +Conformance isolation is not inferred for conformances to protocols that are themselves main actor-isolated, and `InferIsolatedConformances` — which Xcode's *Approachable Concurrency* setting turns on — does not cover that case either. See [SE-0470](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0470-isolated-conformances.md). + +A main actor-isolated module already needs one such annotation per model type against CellKit today; isolating CellKit's own API makes it one per CellKit protocol you conform to. The `Example` app is configured this way and shows what it looks like. Subclasses of `CellModelDataSource`, `AbstractDataSource` and `DifferentiableCellModelDataSource` are main actor-isolated, and so are your subclasses of them. From e97b0bc52213d93404233aa037b1aa3b9883a3fa Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 7 Sep 2026 15:15:00 +0200 Subject: [PATCH 3/7] Add shared Example scheme, scene lifecycle and real UI tests The Example project had no shared scheme, so `xcodebuild test` never built or ran ExampleUITests. Add Example.xcscheme with the UI test bundle in its Test action. Adopt the UIScene lifecycle: iOS 27 terminates apps that still rely on the legacy `AppDelegate.window` path. Add SceneDelegate and the scene manifest, and drop the now redundant UIMainStoryboardFile. The UI test target inherits SWIFT_DEFAULT_ACTOR_ISOLATION from the project; XCTestCase overrides must stay nonisolated, so set the test target back to nonisolated. Replace the placeholder launch test with three tests that exercise the main actor-isolated DifferentiableCellModelDataSource on a real table view: sections render, tapping a cell updates it through a diff, and inserting and resetting rows diffs correctly. Co-Authored-By: Claude Fable 5.1 --- Example/Example.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/xcschemes/Example.xcscheme | 91 +++++++++++++++++++ Example/Sources/Info.plist | 21 ++++- Example/Sources/Supporting/AppDelegate.swift | 3 - .../Sources/Supporting/SceneDelegate.swift | 5 + .../Tests/ExampleUITests/ExampleUITests.swift | 51 ++++++++++- 6 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme create mode 100644 Example/Sources/Supporting/SceneDelegate.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 8081a6f..925c242 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ E7BB267F22D4B63800A7713E /* PhonesHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BB267122D4B63800A7713E /* PhonesHeader.swift */; }; E7BB268022D4B63800A7713E /* PhonesHeaderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BB267222D4B63800A7713E /* PhonesHeaderModel.swift */; }; E7BB268122D4B63800A7713E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BB267322D4B63800A7713E /* AppDelegate.swift */; }; + E7BB268122D4B63800A7713F /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BB267322D4B63800A7713F /* SceneDelegate.swift */; }; E7BCF6BE22DF145F00E71478 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BCF6BD22DF145F00E71478 /* ExampleUITests.swift */; }; /* End PBXBuildFile section */ @@ -66,6 +67,7 @@ E7BB267122D4B63800A7713E /* PhonesHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhonesHeader.swift; sourceTree = ""; }; E7BB267222D4B63800A7713E /* PhonesHeaderModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhonesHeaderModel.swift; sourceTree = ""; }; E7BB267322D4B63800A7713E /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + E7BB267322D4B63800A7713F /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; E7BB267422D4B63800A7713E /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E7BCF6BB22DF145F00E71478 /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E7BCF6BD22DF145F00E71478 /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = ""; }; @@ -141,6 +143,7 @@ isa = PBXGroup; children = ( E7BB267322D4B63800A7713E /* AppDelegate.swift */, + E7BB267322D4B63800A7713F /* SceneDelegate.swift */, BEFF59F523743F1000B3A342 /* Launch.storyboard */, ); path = Supporting; @@ -315,6 +318,7 @@ E7BB268022D4B63800A7713E /* PhonesHeaderModel.swift in Sources */, E7BB267B22D4B63800A7713E /* DeviceiOSCellModel.swift in Sources */, E7BB268122D4B63800A7713E /* AppDelegate.swift in Sources */, + E7BB268122D4B63800A7713F /* SceneDelegate.swift in Sources */, E7BB267F22D4B63800A7713E /* PhonesHeader.swift in Sources */, E7BB267522D4B63800A7713E /* ViewController.swift in Sources */, E7BB267922D4B63800A7713E /* DeviceAndroidCellModel.swift in Sources */, @@ -566,8 +570,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.thefuntasty.ExampleUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Example; @@ -597,8 +600,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Example; diff --git a/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme new file mode 100644 index 0000000..ce41de4 --- /dev/null +++ b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Sources/Info.plist b/Example/Sources/Info.plist index 39efe35..0f2f25e 100644 --- a/Example/Sources/Info.plist +++ b/Example/Sources/Info.plist @@ -22,8 +22,25 @@ UILaunchStoryboardName Storyboard - UIMainStoryboardFile - Main + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIRequiredDeviceCapabilities armv7 diff --git a/Example/Sources/Supporting/AppDelegate.swift b/Example/Sources/Supporting/AppDelegate.swift index 8541c58..0473aa8 100644 --- a/Example/Sources/Supporting/AppDelegate.swift +++ b/Example/Sources/Supporting/AppDelegate.swift @@ -2,9 +2,6 @@ import UIKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { true } diff --git a/Example/Sources/Supporting/SceneDelegate.swift b/Example/Sources/Supporting/SceneDelegate.swift new file mode 100644 index 0000000..1202cac --- /dev/null +++ b/Example/Sources/Supporting/SceneDelegate.swift @@ -0,0 +1,5 @@ +import UIKit + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? +} diff --git a/Example/Tests/ExampleUITests/ExampleUITests.swift b/Example/Tests/ExampleUITests/ExampleUITests.swift index f4951c7..146ed60 100644 --- a/Example/Tests/ExampleUITests/ExampleUITests.swift +++ b/Example/Tests/ExampleUITests/ExampleUITests.swift @@ -1,14 +1,59 @@ import XCTest final class ExampleUITests: XCTestCase { + private var app: XCUIApplication! + private var table: XCUIElement! + override func setUp() { super.setUp() continueAfterFailure = false - XCUIApplication().launch() + app = XCUIApplication() + app.launch() + table = app.tables.firstMatch + } + + func testLaunchShowsSections() { + XCTAssertTrue(table.waitForExistence(timeout: 5)) + XCTAssertTrue(table.staticTexts["Welcome!"].exists) + XCTAssertTrue(table.staticTexts["iPhone tapped: 0"].exists) + XCTAssertGreaterThan(table.cells.count, 1) + } + + func testTapUpdatesCellThroughDiff() { + XCTAssertTrue(table.waitForExistence(timeout: 5)) + + let cell = table.staticTexts["iPhone tapped: 0"] + XCTAssertTrue(cell.exists) + cell.tap() + + XCTAssertTrue(table.staticTexts["iPhone tapped: 1"].waitForExistence(timeout: 5)) + XCTAssertFalse(table.staticTexts["iPhone tapped: 0"].exists) } - func testLaunch() { - XCTAssert(true) + func testInsertAndResetThroughDiff() { + XCTAssertTrue(table.waitForExistence(timeout: 5)) + let firstDeviceCell = table.cells.element(boundBy: 1) + let nameLabel = firstDeviceCell.staticTexts.matching(NSPredicate(format: "label CONTAINS 'tapped:'")).firstMatch + XCTAssertEqual(nameLabel.label, "iPhone tapped: 0") + + app.navigationBars.buttons["+iPhone"].tap() + + let inserted = nameLabel + XCTAssertTrue(inserted.waitForExistence(timeout: 5)) + XCTAssertNotEqual(inserted.label, "iPhone tapped: 0") + XCTAssertTrue(inserted.label.hasSuffix(" tapped: 0")) + XCTAssertTrue(table.staticTexts["iPhone tapped: 0"].exists) + + app.navigationBars.buttons["+Android"].tap() + let insertedAndroid = nameLabel + XCTAssertTrue(insertedAndroid.waitForExistence(timeout: 5)) + XCTAssertFalse(insertedAndroid.label.hasPrefix("iPhone")) + + app.navigationBars.buttons["Reset"].tap() + + let reset = NSPredicate(format: "label == %@", "iPhone tapped: 0") + let expectation = XCTNSPredicateExpectation(predicate: reset, object: nameLabel) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 5), .completed) } } From 0009cb608891ec53da374dec3199828b6bacd5a1 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 7 Sep 2026 15:15:00 +0200 Subject: [PATCH 4/7] Require DifferenceKit 1.3.0 and add Dependabot config 1.3.0 is the only DifferenceKit release with the current API and is what Package.resolved already pins. Dependabot now watches the Swift package and GitHub Actions instead of the removed Gemfile. Note in the README that Swift 6.4 infers the isolated conformances a main actor-isolated consumer otherwise has to write by hand. Co-Authored-By: Claude Fable 5.1 --- .github/dependabot.yml | 10 ++++++++++ Package.swift | 2 +- README.md | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8b3e57f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: swift + directory: / + schedule: + interval: monthly + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/Package.swift b/Package.swift index 82a036e..6239ef4 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( targets: ["DiffableCellKit"]) ], dependencies: [ - .package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.0.0") + .package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0") ], targets: [ .target( diff --git a/README.md b/README.md index 0450fb1..fdbee7f 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Conformance isolation is not inferred for conformances to protocols that are the A main actor-isolated module already needs one such annotation per model type against CellKit today; isolating CellKit's own API makes it one per CellKit protocol you conform to. The `Example` app is configured this way and shows what it looks like. +Swift 6.4 (Xcode 27) infers these conformances correctly and no longer needs the annotations; they are harmless to keep while you still build with Xcode 26. + Subclasses of `CellModelDataSource`, `AbstractDataSource` and `DifferentiableCellModelDataSource` are main actor-isolated, and so are your subclasses of them. ## Usage From fe4a6c743506dec997552f4e7e4778f4301ae4ce Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 7 Sep 2026 15:15:00 +0200 Subject: [PATCH 5/7] Run the Example UI tests in CI Pick the newest iOS runtime and its first available iPhone simulator at run time so the step keeps working when runner images change, and print the toolchain version up front. Keep the job id `test`, which is the required status check on main. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87f9895..d9844ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Toolchain + run: | + xcodebuild -version + swift --version - name: Install SwiftLint run: brew install swiftlint - name: Lint @@ -23,6 +27,15 @@ jobs: run: | xcodebuild -scheme CellKit -destination 'generic/platform=iOS' build xcodebuild -scheme DiffableCellKit -destination 'generic/platform=iOS' build - - name: Build example app + - name: Test example app run: | - xcodebuild -project Example/Example.xcodeproj -scheme Example -destination 'generic/platform=iOS Simulator' -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build + RUNTIME=$(xcrun simctl list runtimes available --json | jq -r '[.runtimes[] | select(.platform == "iOS")] | sort_by(.version | split(".") | map(tonumber)) | last | .identifier') + UDID=$(xcrun simctl list devices available --json | jq -r --arg RT "$RUNTIME" '.devices[$RT][]? | select(.name | test("^iPhone")) | .udid' | head -1) + echo "Using simulator $UDID on $RUNTIME" + xcodebuild test \ + -project Example/Example.xcodeproj \ + -scheme Example \ + -destination "platform=iOS Simulator,id=$UDID" \ + -parallel-testing-enabled NO \ + -retry-tests-on-failure \ + CODE_SIGNING_ALLOWED=NO From 9061970e960fb17f27da37b9fcba4cd044b7058a Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 7 Sep 2026 15:19:15 +0200 Subject: [PATCH 6/7] Fix Example cell heights and make UI tests wait for diffs The cell models declared `cellHeight` as `CGFloat`, which never matched the protocol's `Double` requirement, so every row silently fell back to the 44pt default and the 50pt "Welcome!" label was squeezed out of view. The insert/reset test read the first device cell's label immediately after tapping, racing the animated insert. Wait for the label to change with a predicate expectation instead of assuming it already has. Co-Authored-By: Claude Fable 5.1 --- .../AndroidCell/DeviceAndroidCellModel.swift | 2 +- .../IOSCell/DeviceiOSCellModel.swift | 2 +- .../Table Cells/NibTableViewCell.swift | 2 +- .../Tests/ExampleUITests/ExampleUITests.swift | 35 +++++++++---------- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift b/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift index 570f910..e0ac16d 100644 --- a/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift +++ b/Example/Sources/Table Cells/AndroidCell/DeviceAndroidCellModel.swift @@ -19,7 +19,7 @@ struct DeviceAndroidCellModel: @MainActor CellConvertible, @MainActor Differenti var numberOfTaps: Int let name: String - let cellHeight: CGFloat = 60.0 + let cellHeight: Double = 60.0 let registersLazily: Bool = false let allowDelete: Bool = true } diff --git a/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift b/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift index 088327f..15cfa06 100644 --- a/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift +++ b/Example/Sources/Table Cells/IOSCell/DeviceiOSCellModel.swift @@ -19,7 +19,7 @@ struct DeviceiOSCellModel: @MainActor CellConvertible, @MainActor Differentiable var numberOfTaps: Int let name: String - let cellHeight: CGFloat = 60.0 + let cellHeight: Double = 60.0 let registersLazily: Bool = false let allowDelete: Bool = true } diff --git a/Example/Sources/Table Cells/NibTableViewCell.swift b/Example/Sources/Table Cells/NibTableViewCell.swift index 8ea5a4c..9b0e341 100644 --- a/Example/Sources/Table Cells/NibTableViewCell.swift +++ b/Example/Sources/Table Cells/NibTableViewCell.swift @@ -19,7 +19,7 @@ struct NibTableViewCellModel: @MainActor ReusableCellConvertible, @MainActor Dif typealias Cell = NibTableViewCell let text: String - let cellHeight: CGFloat = 170.0 + let cellHeight: Double = 170.0 } final class NibTableViewCell: UITableViewCell, CellConfigurable { diff --git a/Example/Tests/ExampleUITests/ExampleUITests.swift b/Example/Tests/ExampleUITests/ExampleUITests.swift index 146ed60..010bfcf 100644 --- a/Example/Tests/ExampleUITests/ExampleUITests.swift +++ b/Example/Tests/ExampleUITests/ExampleUITests.swift @@ -11,18 +11,16 @@ final class ExampleUITests: XCTestCase { app = XCUIApplication() app.launch() table = app.tables.firstMatch + XCTAssertTrue(table.waitForExistence(timeout: 5)) } func testLaunchShowsSections() { - XCTAssertTrue(table.waitForExistence(timeout: 5)) XCTAssertTrue(table.staticTexts["Welcome!"].exists) XCTAssertTrue(table.staticTexts["iPhone tapped: 0"].exists) XCTAssertGreaterThan(table.cells.count, 1) } func testTapUpdatesCellThroughDiff() { - XCTAssertTrue(table.waitForExistence(timeout: 5)) - let cell = table.staticTexts["iPhone tapped: 0"] XCTAssertTrue(cell.exists) cell.tap() @@ -32,28 +30,29 @@ final class ExampleUITests: XCTestCase { } func testInsertAndResetThroughDiff() { - XCTAssertTrue(table.waitForExistence(timeout: 5)) - let firstDeviceCell = table.cells.element(boundBy: 1) - let nameLabel = firstDeviceCell.staticTexts.matching(NSPredicate(format: "label CONTAINS 'tapped:'")).firstMatch - XCTAssertEqual(nameLabel.label, "iPhone tapped: 0") + // The name label of whatever device cell is currently first in the "Cell Phones" section. + let firstDeviceName = table.cells.element(boundBy: 1).staticTexts + .matching(NSPredicate(format: "label CONTAINS 'tapped:'")) + .firstMatch + XCTAssertEqual(firstDeviceName.label, "iPhone tapped: 0") app.navigationBars.buttons["+iPhone"].tap() - - let inserted = nameLabel - XCTAssertTrue(inserted.waitForExistence(timeout: 5)) - XCTAssertNotEqual(inserted.label, "iPhone tapped: 0") - XCTAssertTrue(inserted.label.hasSuffix(" tapped: 0")) + wait(until: firstDeviceName, "label != %@", "iPhone tapped: 0") + XCTAssertTrue(firstDeviceName.label.hasPrefix("iPhone")) + XCTAssertTrue(firstDeviceName.label.hasSuffix(" tapped: 0")) XCTAssertTrue(table.staticTexts["iPhone tapped: 0"].exists) app.navigationBars.buttons["+Android"].tap() - let insertedAndroid = nameLabel - XCTAssertTrue(insertedAndroid.waitForExistence(timeout: 5)) - XCTAssertFalse(insertedAndroid.label.hasPrefix("iPhone")) + wait(until: firstDeviceName, "NOT label BEGINSWITH 'iPhone'") + XCTAssertTrue(firstDeviceName.label.hasSuffix(" tapped: 0")) app.navigationBars.buttons["Reset"].tap() + wait(until: firstDeviceName, "label == %@", "iPhone tapped: 0") + } - let reset = NSPredicate(format: "label == %@", "iPhone tapped: 0") - let expectation = XCTNSPredicateExpectation(predicate: reset, object: nameLabel) - XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 5), .completed) + private func wait(until element: XCUIElement, _ format: String, _ arguments: CVarArg..., timeout: TimeInterval = 5) { + let predicate = NSPredicate(format: format, argumentArray: arguments) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, "Timed out waiting for: \(format)") } } From 58489429fc3889869d61a7c683102408790515f5 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 7 Sep 2026 16:11:57 +0200 Subject: [PATCH 7/7] Match CI to FTAPIKit and drop Dependabot config Bump actions/checkout to v7, remove the toolchain diagnostic step, and keep the same lint setup as FTAPIKit (SwiftLint is not preinstalled on macos-latest, so brew install stays). xcodebuild remains in place of `swift build`/`swift test` because CellKit is UIKit-only. Neither FTAPIKit nor FuturedKit ships a dependabot.yml, so remove it here too. Co-Authored-By: Claude Fable 5.1 --- .github/dependabot.yml | 10 ---------- .github/workflows/ci.yml | 7 +------ 2 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 8b3e57f..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 -updates: - - package-ecosystem: swift - directory: / - schedule: - interval: monthly - - package-ecosystem: github-actions - directory: / - schedule: - interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9844ab..3353a0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,7 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@v6 - - name: Toolchain - run: | - xcodebuild -version - swift --version + - uses: actions/checkout@v7 - name: Install SwiftLint run: brew install swiftlint - name: Lint @@ -31,7 +27,6 @@ jobs: run: | RUNTIME=$(xcrun simctl list runtimes available --json | jq -r '[.runtimes[] | select(.platform == "iOS")] | sort_by(.version | split(".") | map(tonumber)) | last | .identifier') UDID=$(xcrun simctl list devices available --json | jq -r --arg RT "$RUNTIME" '.devices[$RT][]? | select(.name | test("^iPhone")) | .udid' | head -1) - echo "Using simulator $UDID on $RUNTIME" xcodebuild test \ -project Example/Example.xcodeproj \ -scheme Example \