diff --git a/Example/Source/Control/NavigationTableViewController.swift b/Example/Source/Control/NavigationTableViewController.swift index 81aa377..a53711a 100644 --- a/Example/Source/Control/NavigationTableViewController.swift +++ b/Example/Source/Control/NavigationTableViewController.swift @@ -14,6 +14,7 @@ import Fluidable class NavigationTableViewController: NavigationBaseViewController, Fluidable { /** Dummy value to prevent UIViewPropertyAnimator from finishing immediately. */ @objc dynamic var transitionProgress: CGFloat = 0 + private var dismissCellContentWidth: CGFloat? @IBOutlet weak var tableView: TableView! @IBOutlet weak var footerOverlayView: UIView! @@ -124,6 +125,7 @@ extension NavigationTableViewController: FluidTransitionDestinationConfiguration "navigation: " + String(describing: navigation), ]) guard transitionStyle.isFluid else { return nil } + self.dismissCellContentWidth = initialDimension.frame().width var animators: [FluidAnimatorCompatible] = [FluidAnimatorCompatible]() /* NOTE: Hide button in 30% of duration without delay (UIViewPropertyAnimator) */ let buttonAnimator: FluidPropertyAnimator = .init(duration: duration * 0.3, /* NOTE: 30% of transition duration */ @@ -172,6 +174,14 @@ extension NavigationTableViewController: FluidTransitionDestinationConfiguration self.view.layoutIfNeeded() }) animators.append(constraintAnimator) + /* NOTE: Table cell layout (UIViewPropertyAnimator) */ + let cellLayoutAnimator: FluidPropertyAnimator = .init(duration: duration, easing: easing, id: "cellLayoutAnimator (Dismiss)") + cellLayoutAnimator.add({ [weak self] in + guard let `self`: NavigationTableViewController = self else { return } + self.layoutVisibleCellsForDismiss() + }) + animators.append(cellLayoutAnimator) + self.layoutVisibleCellsForDismiss() return animators } } @@ -186,6 +196,16 @@ extension NavigationTableViewController: FluidTransitionDestinationActionDelegat "state:".lpad() + String(describing: state), "progress:".lpad() + String(describing: progress), ]) + switch state { + case .begin, .update: + if transitionStyle.isFluid { + self.layoutVisibleCellsForDismiss() + } + case .cancel: + self.resetVisibleCellsAfterDismissCancel() + case .end: + self.dismissCellContentWidth = nil + } } func transitionDismissAnimationDidProgress(from destination: FluidDestinationViewController, to source: FluidSourceViewController, @@ -222,17 +242,33 @@ extension NavigationTableViewController: FluidTransitionDestinationActionDelegat case .update: if transitionStyle.isFluid { self.closeButton.alpha = 1 - progress + self.layoutVisibleCellsForDismiss() } else { self.closeButton.alpha = (1 - progress * 3).clamped(0, 1) } case .cancel: + self.resetVisibleCellsAfterDismissCancel() let animator: UIViewPropertyAnimator = UIViewPropertyAnimator(duration: 0.1, easing: .linear) animator.addAnimations({ [weak self] in self?.closeButton.alpha = 1 }) animator.startAnimation() case .end: - break + self.dismissCellContentWidth = nil } } } + +private extension NavigationTableViewController { + func layoutVisibleCellsForDismiss() { + guard self.tableView.window != nil else { return } + self.view.layoutIfNeeded() + self.tableView.layoutVisibleCellsImmediately(constrainingTextTo: self.dismissCellContentWidth) + } + + func resetVisibleCellsAfterDismissCancel() { + self.dismissCellContentWidth = nil + guard self.tableView.window != nil else { return } + self.tableView.layoutVisibleCellsImmediately() + } +} diff --git a/Example/Source/Control/TransitionTableViewController.swift b/Example/Source/Control/TransitionTableViewController.swift index adf1883..b745f58 100644 --- a/Example/Source/Control/TransitionTableViewController.swift +++ b/Example/Source/Control/TransitionTableViewController.swift @@ -17,6 +17,7 @@ class TransitionTableViewController: TransitionBaseViewController, Fluidable { /** Dummy value to prevent UIViewPropertyAnimator from finishing immediately. */ @objc dynamic var transitionProgress: CGFloat = 0 + private var dismissCellContentWidth: CGFloat? /** Views */ @IBOutlet weak var tableView: TableView! @@ -148,6 +149,7 @@ extension TransitionTableViewController: FluidTransitionDestinationConfiguration "navigation: " + String(describing: navigation), ]) guard transitionStyle.isFluid else { return nil } + self.dismissCellContentWidth = initialDimension.frame().width var animators: [FluidAnimatorCompatible] = [FluidAnimatorCompatible]() /* NOTE: Hide button in 30% of duration without delay (Core Animation) */ // if let buttonAnimator: FluidCoreAnimator = FluidCoreAnimator(for: self.closeButton.layer, id: "buttonAnimator (Dismiss)", @@ -208,6 +210,14 @@ extension TransitionTableViewController: FluidTransitionDestinationConfiguration self.view.layoutIfNeeded() }) animators.append(constraintAnimator) + /* NOTE: Table cell layout (UIViewPropertyAnimator) */ + let cellLayoutAnimator: FluidPropertyAnimator = .init(duration: duration, easing: easing, id: "cellLayoutAnimator (Dismiss)") + cellLayoutAnimator.add({ [weak self] in + guard let `self`: TransitionTableViewController = self else { return } + self.layoutVisibleCellsForDismiss() + }) + animators.append(cellLayoutAnimator) + self.layoutVisibleCellsForDismiss() return animators } } @@ -238,6 +248,16 @@ extension TransitionTableViewController: FluidTransitionDestinationActionDelegat "state:".lpad() + String(describing: state), "progress:".lpad() + String(describing: progress), ]) + switch state { + case .begin, .update: + if transitionStyle.isFluid { + self.layoutVisibleCellsForDismiss() + } + case .cancel: + self.resetVisibleCellsAfterDismissCancel() + case .end: + self.dismissCellContentWidth = nil + } } func transitionPresentInteractionDidProgress(from source: FluidSourceViewController, to destination: FluidDestinationViewController, @@ -264,17 +284,33 @@ extension TransitionTableViewController: FluidTransitionDestinationActionDelegat case .update: if transitionStyle.isFluid { self.closeButton.alpha = 1 - progress + self.layoutVisibleCellsForDismiss() } else { self.closeButton.alpha = (1 - progress * 3).clamped(0, 1) } case .cancel: + self.resetVisibleCellsAfterDismissCancel() let animator: UIViewPropertyAnimator = UIViewPropertyAnimator(duration: 0.1, easing: .linear) animator.addAnimations({ [weak self] in self?.closeButton.alpha = 1 }) animator.startAnimation() case .end: - break + self.dismissCellContentWidth = nil } } } + +private extension TransitionTableViewController { + func layoutVisibleCellsForDismiss() { + guard self.tableView.window != nil else { return } + self.view.layoutIfNeeded() + self.tableView.layoutVisibleCellsImmediately(constrainingTextTo: self.dismissCellContentWidth) + } + + func resetVisibleCellsAfterDismissCancel() { + self.dismissCellContentWidth = nil + guard self.tableView.window != nil else { return } + self.tableView.layoutVisibleCellsImmediately() + } +} diff --git a/Example/Source/Helper/Extensions.swift b/Example/Source/Helper/Extensions.swift index 0b5a49f..d658552 100644 --- a/Example/Source/Helper/Extensions.swift +++ b/Example/Source/Helper/Extensions.swift @@ -63,7 +63,7 @@ extension UINib { extension UITableView { func register(cellType: T.Type) { let className: String = cellType.className - let nib = UINib(nibName: className, bundle: nil) + let nib = UINib(nibName: className, bundle: Bundle(for: cellType)) register(nib, forCellReuseIdentifier: className) } diff --git a/Example/Source/View/HeaderCell.swift b/Example/Source/View/HeaderCell.swift index 0cbc739..52298a0 100644 --- a/Example/Source/View/HeaderCell.swift +++ b/Example/Source/View/HeaderCell.swift @@ -19,7 +19,7 @@ class HeaderCell: UITableViewCell { required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } static func instantiate(model: RootModel) -> HeaderCell { - let view: HeaderCell = UINib.instantiate(nibName: className) + let view: HeaderCell = UINib.instantiate(nibName: className, bundle: Bundle(for: HeaderCell.self)) view.configure(model: model) return view } diff --git a/Example/Source/View/TableCell.swift b/Example/Source/View/TableCell.swift index 00be7c8..2b58e01 100644 --- a/Example/Source/View/TableCell.swift +++ b/Example/Source/View/TableCell.swift @@ -10,6 +10,10 @@ import Foundation import UIKit class TableCell: UITableViewCell { + private struct Const { + static let titleTrailingMargin: CGFloat = 20 + } + @IBOutlet weak var thumbView: UIImageView! @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var captionLabel: UILabel! @@ -30,4 +34,15 @@ class TableCell: UITableViewCell { self.clipsToBounds = true } + + func layoutTextLabels(forContentWidth contentWidth: CGFloat) { + self.contentView.layoutIfNeeded() + self.titleLabel.lineBreakMode = .byTruncatingTail + self.captionLabel.lineBreakMode = .byTruncatingTail + + let titleWidth: CGFloat = contentWidth - self.titleLabel.frame.minX - Const.titleTrailingMargin + let captionWidth: CGFloat = contentWidth - self.captionLabel.frame.minX - self.contentView.layoutMargins.right + self.titleLabel.frame.size.width = max(0, titleWidth) + self.captionLabel.frame.size.width = max(0, captionWidth) + } } diff --git a/Example/Source/View/TableView.swift b/Example/Source/View/TableView.swift index ff70a96..d9556ce 100644 --- a/Example/Source/View/TableView.swift +++ b/Example/Source/View/TableView.swift @@ -38,6 +38,20 @@ extension TableView { /* NOTE: Reload */ self.reloadData() } + + func layoutVisibleCellsImmediately(constrainingTextTo contentWidth: CGFloat? = nil) { + self.setNeedsLayout() + self.layoutIfNeeded() + self.visibleCells.forEach { + $0.setNeedsLayout() + $0.contentView.setNeedsLayout() + $0.contentView.layoutIfNeeded() + $0.layoutIfNeeded() + guard let contentWidth: CGFloat = contentWidth, + let cell: TableCell = $0 as? TableCell else { return } + cell.layoutTextLabels(forContentWidth: contentWidth) + } + } } extension TableView: UITableViewDataSource { diff --git a/UITests/MainSpec.swift b/UITests/MainSpec.swift index 433ca1d..9abf182 100644 --- a/UITests/MainSpec.swift +++ b/UITests/MainSpec.swift @@ -150,3 +150,86 @@ final class MainSpec: QuickSpec { } } + +final class FluidModalTableDismissalLayoutTests: XCTestCase { + class TestFluidSourceViewController: UIViewController, Fluidable {} + + func testConstrainsTableCellLabelsToDismissTargetWidth() { + let bundle = Bundle(for: FluidModalTableDismissalLayoutTests.self) + let cell = UINib(nibName: "TableCell", bundle: bundle) + .instantiate(withOwner: nil, options: nil).first as! TableCell + cell.frame = CGRect(x: 0, y: 0, width: 430, height: 64) + cell.contentView.frame = cell.bounds + cell.configure(row: 0) + cell.layoutIfNeeded() + + cell.layoutTextLabels(forContentWidth: 390) + + XCTAssertEqual(cell.titleLabel.lineBreakMode, .byTruncatingTail) + XCTAssertEqual(cell.captionLabel.lineBreakMode, .byTruncatingTail) + XCTAssertEqual(cell.titleLabel.frame.width, + 390 - cell.titleLabel.frame.minX - 20, + accuracy: 0.5) + XCTAssertLessThan(cell.titleLabel.frame.maxX, cell.contentView.frame.maxX) + } + + func testUpdatesVisibleTableCellsWhileTheModalShrinks() { + let model: RootModel = .navigationFluidFullScreen + let bundle = Bundle(for: FluidModalTableDismissalLayoutTests.self) + let navigation = UINib(nibName: "NavigationRootNavigationController", bundle: bundle) + .instantiate(withOwner: nil, options: nil).first as! NavigationRootNavigationController + let destination = UINib(nibName: "NavigationTableViewController", bundle: bundle) + .instantiate(withOwner: nil, options: nil).first as! NavigationTableViewController + let source: TestFluidSourceViewController = TestFluidSourceViewController() + + navigation.configure(modelIndex: model.rawValue) + navigation.pushViewController(destination, animated: false) + destination.configure(modelIndex: model.rawValue) + destination.loadViewIfNeeded() + source.loadViewIfNeeded() + + let animators: [FluidAnimatorCompatible] = destination.transitionAdditionalDismissAnimations( + from: destination, + to: source, + with: navigation, + on: UIView(frame: CGRect(x: 0, y: 0, width: 430, height: 930)), + initialDimension: FluidInitialFrameDimension(for: model.transitionStyle, + contentOrigin: CGPoint(x: 20, y: 100), + contentSize: CGSize(width: 390, height: 700), + contentTransform: CATransform3DIdentity), + finalDimension: FluidFinalFrameDimension(for: model.transitionStyle, + portraitContentSize: CGSize(width: 430, height: 930), + landscapeContentSize: CGSize(width: 930, height: 430), + portraitContentTransform: CATransform3DIdentity, + landscapeContentTransform: CATransform3DIdentity), + initialStyle: model.initialFrameStyle!, + finalStyle: model.finalFrameStyle!, + transitionStyle: model.transitionStyle, + duration: 0.5, + easing: FluidAnimatorEasing.linear) ?? [] + + XCTAssertTrue(animators.contains { $0.identifier == "cellLayoutAnimator (Dismiss)" }) + } +} + +final class NavigationFluidFullScreenDismissUITests: XCTestCase { + func testFinishAnimatedDismissWithTableContent() { + let app: XCUIApplication = XCUIApplication() + let orientation: UIDeviceOrientation = .portrait + let model: RootModel = .navigationFluidFullScreen + + addTeardownBlock { + app.terminate() + XCUIDevice.shared.orientation = .portrait + } + + XCUIDevice.shared.orientation = orientation + app.setEnv(MainSpec.env) + app.launch() + + MainSpec.finishAnimatedPresent(app: app, orientation: orientation, model: model) + MainSpec.pushViewController(app: app, orientation: orientation, model: model) + MainSpec.popViewControllerByTappingBackButton(app: app, orientation: orientation, model: model) + MainSpec.finishAnimatedDismissByTappingContainer(app: app, orientation: orientation, model: model) + } +}