diff --git a/Sources/ConfigurableCell.swift b/Sources/ConfigurableCell.swift index 349a401..02817a7 100644 --- a/Sources/ConfigurableCell.swift +++ b/Sources/ConfigurableCell.swift @@ -25,10 +25,15 @@ public protocol ConfigurableCell { associatedtype CellData static var reuseIdentifier: String { get } + static var estimatedHeight: CGFloat? { get } + static var defaultHeight: CGFloat? { get } + static var layoutType: LayoutType { get } + func configure(with _: CellData) + } public extension ConfigurableCell where Self: UITableViewCell { @@ -44,4 +49,9 @@ public extension ConfigurableCell where Self: UITableViewCell { static var defaultHeight: CGFloat? { return nil } + + static var layoutType: LayoutType { + return .auto + } + } diff --git a/Sources/Expandable.swift b/Sources/Expandable.swift new file mode 100644 index 0000000..847965f --- /dev/null +++ b/Sources/Expandable.swift @@ -0,0 +1,70 @@ +import UIKit + +public protocol Expandable { + + associatedtype ViewModelType: ExpandableCellViewModel + + var viewModel: ViewModelType? { get } + + func configureAppearance(isCollapsed: Bool) + +} + +extension Expandable where Self: UITableViewCell & ConfigurableCell { + + public func initState() { + guard let viewModel = viewModel else { + return + } + + changeState(isCollapsed: viewModel.isCollapsed) + } + + private func changeState(isCollapsed: Bool) { + // layout to get right frames, frame of bottom subview can be used to get expanded height + layoutIfNeeded() + + // apply changes + configureAppearance(isCollapsed: isCollapsed) + layoutIfNeeded() + } + + public func toggleState(animated: Bool = true, + animationDuration: TimeInterval = 0.3) { + + guard let tableView = tableView, + let viewModel = viewModel else { + return + } + + let contentOffset = tableView.contentOffset + + if animated { + UIView.animate(withDuration: animationDuration, + animations: { [weak self] in + self?.applyChanges(isCollapsed: !viewModel.isCollapsed) + }, completion: { _ in + viewModel.isCollapsed.toggle() + }) + } else { + applyChanges(isCollapsed: !viewModel.isCollapsed) + viewModel.isCollapsed.toggle() + } + + tableView.beginUpdates() + tableView.endUpdates() + + tableView.setContentOffset(contentOffset, animated: false) + } + + private func applyChanges(isCollapsed: Bool) { + changeState(isCollapsed: isCollapsed) + + if let indexPath = indexPath, + let tableDirector = (tableView?.delegate as? TableDirector), + let cellHeightCalculator = tableDirector.rowHeightCalculator as? ExpandableCellHeightCalculator { + cellHeightCalculator.updateCached(height: height(layoutType: Self.layoutType), for: indexPath) + } + } + +} diff --git a/Sources/ExpandableCellHeightCalculator.swift b/Sources/ExpandableCellHeightCalculator.swift new file mode 100644 index 0000000..351d342 --- /dev/null +++ b/Sources/ExpandableCellHeightCalculator.swift @@ -0,0 +1,55 @@ +import UIKit + +public final class ExpandableCellHeightCalculator: RowHeightCalculator { + + private weak var tableView: UITableView? + + private var prototypes = [String: UITableViewCell]() + + private var cachedHeights = [IndexPath: CGFloat]() + + public init(tableView: UITableView?) { + self.tableView = tableView + } + + public func updateCached(height: CGFloat, for indexPath: IndexPath) { + cachedHeights[indexPath] = height + } + + public func height(forRow row: Row, at indexPath: IndexPath) -> CGFloat { + + guard let tableView = tableView else { + return 0 + } + + if let height = cachedHeights[indexPath] { + return height + } + + var prototypeCell = prototypes[row.reuseIdentifier] + if prototypeCell == nil { + prototypeCell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier) + prototypes[row.reuseIdentifier] = prototypeCell + } + + guard let cell = prototypeCell else { + return 0 + } + + row.configure(cell) + cell.layoutIfNeeded() + + let height = cell.height(layoutType: row.layoutType) + cachedHeights[indexPath] = height + return height + } + + public func estimatedHeight(forRow row: Row, at indexPath: IndexPath) -> CGFloat { + return height(forRow: row, at: indexPath) + } + + public func invalidate() { + cachedHeights.removeAll() + } + +} diff --git a/Sources/ExpandableCellViewModel.swift b/Sources/ExpandableCellViewModel.swift new file mode 100644 index 0000000..f3dd8ea --- /dev/null +++ b/Sources/ExpandableCellViewModel.swift @@ -0,0 +1,5 @@ +public protocol ExpandableCellViewModel: class { + + var isCollapsed: Bool { get set } + +} diff --git a/Sources/LayoutType.swift b/Sources/LayoutType.swift new file mode 100644 index 0000000..328ba4c --- /dev/null +++ b/Sources/LayoutType.swift @@ -0,0 +1,7 @@ +public enum LayoutType { + + case manual + + case auto + +} diff --git a/Sources/TableKit.swift b/Sources/TableKit.swift index 526b378..0615871 100644 --- a/Sources/TableKit.swift +++ b/Sources/TableKit.swift @@ -33,6 +33,7 @@ public struct TableKitUserInfoKeys { public protocol RowConfigurable { func configure(_ cell: UITableViewCell) + } public protocol RowActionable { @@ -58,7 +59,8 @@ public protocol Row: RowConfigurable, RowActionable, RowHashable { var reuseIdentifier: String { get } var cellType: AnyClass { get } - + + var layoutType: LayoutType { get } var estimatedHeight: CGFloat? { get } var defaultHeight: CGFloat? { get } } diff --git a/Sources/TableRow.swift b/Sources/TableRow.swift index b04382d..662b4dd 100644 --- a/Sources/TableRow.swift +++ b/Sources/TableRow.swift @@ -41,6 +41,10 @@ open class TableRow: Row where CellType: UITableView open var defaultHeight: CGFloat? { return CellType.defaultHeight } + + open var layoutType: LayoutType { + return CellType.layoutType + } open var cellType: AnyClass { return CellType.self @@ -59,7 +63,7 @@ open class TableRow: Row where CellType: UITableView (cell as? CellType)?.configure(with: item) } - + // MARK: - RowActionable - open func invoke(action: TableRowActionType, cell: UITableViewCell?, path: IndexPath, userInfo: [AnyHashable: Any]? = nil) -> Any? { diff --git a/Sources/UITableViewCell+Extensions.swift b/Sources/UITableViewCell+Extensions.swift new file mode 100644 index 0000000..6709bf4 --- /dev/null +++ b/Sources/UITableViewCell+Extensions.swift @@ -0,0 +1,32 @@ +import UIKit + +extension UITableViewCell { + + var tableView: UITableView? { + var view = superview + + while view != nil && !(view is UITableView) { + view = view?.superview + } + + return view as? UITableView + } + + var indexPath: IndexPath? { + guard let indexPath = tableView?.indexPath(for: self) else { + return nil + } + + return indexPath + } + + public func height(layoutType: LayoutType) -> CGFloat { + switch layoutType { + case .auto: + return contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height + case .manual: + return contentView.subviews.map { $0.frame.maxY }.max() ?? 0 + } + } + +} diff --git a/TableKit.xcodeproj/project.pbxproj b/TableKit.xcodeproj/project.pbxproj index d20f8ba..60fa84d 100644 --- a/TableKit.xcodeproj/project.pbxproj +++ b/TableKit.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 3201E78421BE9DE1001DF9E7 /* ExpandableCellHeightCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3201E78321BE9DE1001DF9E7 /* ExpandableCellHeightCalculator.swift */; }; + 3201E78621BE9E25001DF9E7 /* UITableViewCell+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3201E78521BE9E25001DF9E7 /* UITableViewCell+Extensions.swift */; }; + 3201E78821BE9EB2001DF9E7 /* Expandable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3201E78721BE9EB2001DF9E7 /* Expandable.swift */; }; + 3201E78A21BE9ED4001DF9E7 /* ExpandableCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3201E78921BE9ED4001DF9E7 /* ExpandableCellViewModel.swift */; }; + 32BDFE9F21C167F400D0BBB4 /* LayoutType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32BDFE9E21C167F400D0BBB4 /* LayoutType.swift */; }; 50CF6E6B1D6704FE004746FF /* TableCellRegisterer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF6E6A1D6704FE004746FF /* TableCellRegisterer.swift */; }; 50E858581DB153F500A9AA55 /* TableKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E858571DB153F500A9AA55 /* TableKit.swift */; }; DA9EA7AF1D0EC2C90021F650 /* ConfigurableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9EA7A61D0EC2C90021F650 /* ConfigurableCell.swift */; }; @@ -32,6 +37,11 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3201E78321BE9DE1001DF9E7 /* ExpandableCellHeightCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableCellHeightCalculator.swift; sourceTree = ""; }; + 3201E78521BE9E25001DF9E7 /* UITableViewCell+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+Extensions.swift"; sourceTree = ""; }; + 3201E78721BE9EB2001DF9E7 /* Expandable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Expandable.swift; sourceTree = ""; }; + 3201E78921BE9ED4001DF9E7 /* ExpandableCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableCellViewModel.swift; sourceTree = ""; }; + 32BDFE9E21C167F400D0BBB4 /* LayoutType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutType.swift; sourceTree = ""; }; 50CF6E6A1D6704FE004746FF /* TableCellRegisterer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableCellRegisterer.swift; sourceTree = ""; }; 50E858571DB153F500A9AA55 /* TableKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableKit.swift; sourceTree = ""; }; DA9EA7561D0B679A0021F650 /* TableKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TableKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -90,16 +100,21 @@ DA9EA7A51D0EC2B90021F650 /* Sources */ = { isa = PBXGroup; children = ( - 50E858571DB153F500A9AA55 /* TableKit.swift */, - DA9EA7AA1D0EC2C90021F650 /* TableDirector.swift */, + DA9EA7A61D0EC2C90021F650 /* ConfigurableCell.swift */, + 3201E78321BE9DE1001DF9E7 /* ExpandableCellHeightCalculator.swift */, + DA9EA7A81D0EC2C90021F650 /* Operators.swift */, + DA9EA7A91D0EC2C90021F650 /* TableCellAction.swift */, 50CF6E6A1D6704FE004746FF /* TableCellRegisterer.swift */, + DA9EA7AA1D0EC2C90021F650 /* TableDirector.swift */, + 50E858571DB153F500A9AA55 /* TableKit.swift */, + DA9EA7A71D0EC2C90021F650 /* TablePrototypeCellHeightCalculator.swift */, DA9EA7AB1D0EC2C90021F650 /* TableRow.swift */, DA9EA7AC1D0EC2C90021F650 /* TableRowAction.swift */, DA9EA7AE1D0EC2C90021F650 /* TableSection.swift */, - DA9EA7A91D0EC2C90021F650 /* TableCellAction.swift */, - DA9EA7A71D0EC2C90021F650 /* TablePrototypeCellHeightCalculator.swift */, - DA9EA7A61D0EC2C90021F650 /* ConfigurableCell.swift */, - DA9EA7A81D0EC2C90021F650 /* Operators.swift */, + 3201E78521BE9E25001DF9E7 /* UITableViewCell+Extensions.swift */, + 3201E78721BE9EB2001DF9E7 /* Expandable.swift */, + 3201E78921BE9ED4001DF9E7 /* ExpandableCellViewModel.swift */, + 32BDFE9E21C167F400D0BBB4 /* LayoutType.swift */, ); path = Sources; sourceTree = ""; @@ -231,13 +246,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3201E78A21BE9ED4001DF9E7 /* ExpandableCellViewModel.swift in Sources */, 50CF6E6B1D6704FE004746FF /* TableCellRegisterer.swift in Sources */, DA9EA7AF1D0EC2C90021F650 /* ConfigurableCell.swift in Sources */, DA9EA7B31D0EC2C90021F650 /* TableDirector.swift in Sources */, + 3201E78821BE9EB2001DF9E7 /* Expandable.swift in Sources */, DA9EA7B71D0EC2C90021F650 /* TableSection.swift in Sources */, DA9EA7B01D0EC2C90021F650 /* TablePrototypeCellHeightCalculator.swift in Sources */, + 3201E78421BE9DE1001DF9E7 /* ExpandableCellHeightCalculator.swift in Sources */, DA9EA7B51D0EC2C90021F650 /* TableRowAction.swift in Sources */, DA9EA7B21D0EC2C90021F650 /* TableCellAction.swift in Sources */, + 32BDFE9F21C167F400D0BBB4 /* LayoutType.swift in Sources */, + 3201E78621BE9E25001DF9E7 /* UITableViewCell+Extensions.swift in Sources */, DA9EA7B11D0EC2C90021F650 /* Operators.swift in Sources */, DA9EA7B41D0EC2C90021F650 /* TableRow.swift in Sources */, 50E858581DB153F500A9AA55 /* TableKit.swift in Sources */,