Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move ImmuTable to dedicated package #24152

Draft
wants to merge 6 commits into
base: trunk
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
root = true

# Apply to all files
[*]
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

[*.swift]
indent_size = 4

[{*.h,*.m}]
indent_size = 4

# Ruby specific rules
[{*.rb,Fastfile,Gemfile}]
indent_style = space
3 changes: 3 additions & 0 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ let package = Package(
products: XcodeSupport.products + [
.library(name: "AsyncImageKit", targets: ["AsyncImageKit"]),
.library(name: "DesignSystem", targets: ["DesignSystem"]),
.library(name: "ImmuTable", targets: ["ImmuTable"]),
.library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]),
.library(name: "WordPressFlux", targets: ["WordPressFlux"]),
.library(name: "WordPressShared", targets: ["WordPressShared"]),
@@ -58,6 +59,7 @@ let package = Package(
.product(name: "Gifu", package: "Gifu"),
]),
.target(name: "DesignSystem", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "ImmuTable", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "UITestsFoundation", dependencies: [
.product(name: "ScreenObject", package: "ScreenObject"),
@@ -158,6 +160,7 @@ enum XcodeSupport {
return [
.xcodeTarget("XcodeTarget_App", dependencies: [
"DesignSystem",
"ImmuTable",
"JetpackStatsWidgetsCore",
"WordPressFlux",
"WordPressShared",
15 changes: 15 additions & 0 deletions Modules/Sources/ImmuTable/AnyHashableImmuTableRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
public struct AnyHashableImmuTableRow: Hashable {
public let immuTableRow: any (ImmuTableRow & Hashable)

public init(immuTableRow: any (ImmuTableRow & Hashable)) {
self.immuTableRow = immuTableRow
}

public static func == (lhs: AnyHashableImmuTableRow, rhs: AnyHashableImmuTableRow) -> Bool {
return AnyHashable(lhs.immuTableRow) == AnyHashable(rhs.immuTableRow)
}

public func hash(into hasher: inout Hasher) {
hasher.combine(AnyHashable(immuTableRow))
}
}
3 changes: 3 additions & 0 deletions Modules/Sources/ImmuTable/CellRegistrar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
protocol CellRegistrar {
func register(_ cell: ImmuTableCell, cellReuseIdentifier: String)
}
3 changes: 3 additions & 0 deletions Modules/Sources/ImmuTable/Hashable+Xcode16Workaround.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This conformance was added during the Xcode 16 migration to silence the
// dozens of false-positive warnings (any @unchecked conformance is tech debt).
extension AnyHashable: @retroactive @unchecked Sendable {}
6 changes: 6 additions & 0 deletions Modules/Sources/ImmuTable/ImmuTable+Empty.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
public extension ImmuTable {
/// Alias for an ImmuTable with no sections
static var Empty: ImmuTable {
return ImmuTable(sections: [])
}
}
74 changes: 74 additions & 0 deletions Modules/Sources/ImmuTable/ImmuTable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import UIKit

/**
ImmuTable represents the view model for a static UITableView.

ImmuTable consists of zero or more sections, each one containing zero or more rows,
and an optional header and footer text.

Each row contains the model necessary to configure a specific type of UITableViewCell.

To use ImmuTable, first you need to create some custom rows. An example row for a cell
that acts as a button which performs a destructive action could look like this:

struct DestructiveButtonRow: ImmuTableRow {
static let cell = ImmuTableCell.Class(UITableViewCell.self)
let title: String
let action: ImmuTableAction?

func configureCell(cell: UITableViewCell) {
cell.textLabel?.text = title
cell.textLabel?.textAlignment = .Center
cell.textLabel?.textColor = UIColor.redColor()
}
}

The easiest way to use ImmuTable is through ImmuTableViewHandler, which takes a
UITableViewController as an argument, and acts as the table view delegate and data
source. You would then assign an ImmuTable object to the handler's `viewModel`
property.

- attention: before using any ImmuTableRow type, you need to call `registerRows(_:tableView:)`
passing the row type. This is needed so ImmuTable can register the class or nib with the table view.
If you fail to do this, UIKit will raise an exception when it tries to load the row.
*/
public struct ImmuTable {
/// An array of the sections to be represented in the table view
public let sections: [ImmuTableSection]

/// Initializes an ImmuTable object with the given sections
public init(sections: [ImmuTableSection]) {
self.sections = sections
}

/// Returns the row model for a specific index path.
///
/// - Precondition: `indexPath` should represent a valid section and row, otherwise this method
/// will raise an exception.
///
public func rowAtIndexPath(_ indexPath: IndexPath) -> ImmuTableRow {
return sections[indexPath.section].rows[indexPath.row]
}

/// Registers the row custom class or nib with the table view so it can later be
/// dequeued with `dequeueReusableCellWithIdentifier(_:forIndexPath:)`
///
public static func registerRows(_ rows: [ImmuTableRow.Type], tableView: UITableView) {
registerRows(rows, registrator: tableView)
}

/// This function exists for testing purposes
/// - seealso: registerRows(_:tableView:)
internal static func registerRows(_ rows: [ImmuTableRow.Type], registrator: CellRegistrar) {
let registrables = rows.reduce([:]) {
(classes, row) -> [String: ImmuTableCell] in

var classes = classes
classes[row.cell.reusableIdentifier] = row.cell
return classes
}
for (identifier, registrable) in registrables {
registrator.register(registrable, cellReuseIdentifier: identifier)
}
}
}
1 change: 1 addition & 0 deletions Modules/Sources/ImmuTable/ImmuTableAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public typealias ImmuTableAction = (ImmuTableRow) -> Void
41 changes: 41 additions & 0 deletions Modules/Sources/ImmuTable/ImmuTableCell.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import UIKit

// ImmuTableCell describes cell types so they can be registered with a table view.
///
/// It supports two options:
/// - Nib for Interface Builder defined cells.
/// - Class for cells defined in code.
/// Both cases presume a custom UITableViewCell subclass. If you aren't subclassing,
/// you can also use UITableViewCell as the type.
///
/// - Note: If you need to use any cell style other than .Default we recommend you
/// subclass UITableViewCell and override init(style:reuseIdentifier:).
///
public enum ImmuTableCell {

/// A cell using a UINib. Values are the UINib object and the custom cell class.
case nib(UINib, UITableViewCell.Type)

/// A cell using a custom class. The associated value is the custom cell class.
case `class`(UITableViewCell.Type)

/// A String that uniquely identifies the cell type
public var reusableIdentifier: String {
switch self {
case .class(let cellClass):
return NSStringFromClass(cellClass)
case .nib(_, let cellClass):
return NSStringFromClass(cellClass)
}
}

/// The class of the custom cell
public var cellClass: UITableViewCell.Type {
switch self {
case .class(let cellClass):
return cellClass
case .nib(_, let cellClass):
return cellClass
}
}
}
3 changes: 3 additions & 0 deletions Modules/Sources/ImmuTable/ImmuTableDiffableDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import UIKit

public typealias ImmuTableDiffableDataSource = UITableViewDiffableDataSource<AnyHashable, AnyHashableImmuTableRow>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import UIKit

public typealias ImmuTableDiffableDataSourceSnapshot = NSDiffableDataSourceSnapshot<AnyHashable, AnyHashableImmuTableRow>
45 changes: 45 additions & 0 deletions Modules/Sources/ImmuTable/ImmuTableDiffableViewHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import UIKit

public class ImmuTableDiffableViewHandler: ImmuTableViewHandler {
public lazy var diffableDataSource: ImmuTableDiffableDataSource = {
return ImmuTableDiffableDataSource(tableView: target.tableView) { tableView, indexPath, item in
let row = item.immuTableRow
let cell = tableView.dequeueReusableCell(withIdentifier: row.reusableIdentifier, for: indexPath)
row.configureCell(cell)
return cell
}
}()

public override init(takeOver target: UIViewControllerWithTableView, with passthroughScrollViewDelegate: UIScrollViewDelegate? = nil) {
super.init(takeOver: target, with: passthroughScrollViewDelegate)

self.target.tableView.dataSource = diffableDataSource
self.automaticallyReloadTableView = false
}

func item(for indexPath: IndexPath) -> ImmuTableRow? {
guard let diffableDataSource = target.tableView.dataSource as? UITableViewDiffableDataSource<AnyHashable, AnyHashableImmuTableRow> else {
return nil
}

return diffableDataSource.itemIdentifier(for: indexPath)?.immuTableRow
}

open override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if target.responds(to: #selector(UITableViewDelegate.tableView(_:didSelectRowAt:))) {
target.tableView?(tableView, didSelectRowAt: indexPath)
} else if let item = item(for: indexPath) {
item.action?(item)
}
if automaticallyDeselectCells {
tableView.deselectRow(at: indexPath, animated: true)
}
}

open override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if let item = item(for: indexPath), let customHeight = type(of: item).customHeight {
return CGFloat(customHeight)
}
return tableView.rowHeight
}
}
73 changes: 73 additions & 0 deletions Modules/Sources/ImmuTable/ImmuTableRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import UIKit

/// ImmuTableRow represents the minimum common elements of a row model.
///
/// You should implement your own types that conform to ImmuTableRow to define your custom rows.
///
public protocol ImmuTableRow {

/**
The closure to call when the row is tapped. The row is passed as an argument to the closure.

To improve readability, we recommend that you implement the action logic in one of
your view controller methods, instead of including the closure inline.

Also, be mindful of retain cycles. If your closure needs to reference `self` in
any way, make sure to use `[unowned self]` in the parameter list.

An example row with its action could look like this:

class ViewController: UITableViewController {

func buildViewModel() {
let item1Row = NavigationItemRow(title: "Item 1", action: navigationAction())
...
}

func navigationAction() -> ImmuTableRow -> Void {
return { [unowned self] row in
let controller = self.controllerForRow(row)
self.navigationController?.pushViewController(controller, animated: true)
}
}

...

}

*/
var action: ImmuTableAction? { get }

/// This method is called when an associated cell needs to be configured.
///
/// - Precondition: You can assume that the passed cell is of the type defined
/// by cell.cellClass and force downcast accordingly.
///
func configureCell(_ cell: UITableViewCell)

/// An ImmuTableCell value defining the associated cell type.
///
/// - Seealso: See ImmuTableCell for possible options.
///
static var cell: ImmuTableCell { get }

/// The desired row height (Optional)
///
/// If not defined or nil, the default height will be used.
///
static var customHeight: Float? { get }
}

extension ImmuTableRow {
public var reusableIdentifier: String {
return type(of: self).cell.reusableIdentifier
}

public var cellClass: UITableViewCell.Type {
return type(of: self).cell.cellClass
}

public static var customHeight: Float? {
return nil
}
}
17 changes: 17 additions & 0 deletions Modules/Sources/ImmuTable/ImmuTableSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// ImmuTableSection represents the view model for a table view section.
///
/// A section has an optional header and footer text, and zero or more rows.
/// - seealso: ImmuTableRow
///
public struct ImmuTableSection {
public let headerText: String?
public let rows: [ImmuTableRow]
public let footerText: String?

/// Initializes a ImmuTableSection with the given rows and optionally header and footer text
public init(headerText: String? = nil, rows: [ImmuTableRow], footerText: String? = nil) {
self.headerText = headerText
self.rows = rows
self.footerText = footerText
}
}
Loading