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

Adds a scroll offset manager class which caches and restores scroll offsets #63

Merged
merged 7 commits into from
Sep 3, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
32 changes: 32 additions & 0 deletions ThunderTable.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
B1AD7EC31D8C194400BFCA34 /* InputSwitchRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1AD7EC21D8C194400BFCA34 /* InputSwitchRow.swift */; };
B1AD7EC61D8C198F00BFCA34 /* InputSwitchViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1AD7EC41D8C198F00BFCA34 /* InputSwitchViewCell.swift */; };
B1AD7EC71D8C198F00BFCA34 /* InputSwitchViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B1AD7EC51D8C198F00BFCA34 /* InputSwitchViewCell.xib */; };
B1B5C6D624FFCAAD00F05CE8 /* ScrollOffsetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B5C6D424FFCAAD00F05CE8 /* ScrollOffsetManager.swift */; };
B1B5C6D724FFCAAD00F05CE8 /* ScrollOffsetManagable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B5C6D524FFCAAD00F05CE8 /* ScrollOffsetManagable.swift */; };
B1B5C6DA24FFD6DB00F05CE8 /* CollectionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B5C6D824FFD6DB00F05CE8 /* CollectionTableViewCell.swift */; };
B1B5C6DB24FFD6DB00F05CE8 /* CollectionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B1B5C6D924FFD6DB00F05CE8 /* CollectionTableViewCell.xib */; };
B1B5C6DD24FFDBED00F05CE8 /* CollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B5C6DC24FFDBED00F05CE8 /* CollectionViewCell.swift */; };
B1B5C6DF24FFDC4000F05CE8 /* CollectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B5C6DE24FFDC4000F05CE8 /* CollectionRow.swift */; };
B1B679611D89807A00B66FD8 /* TableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B6795F1D89807A00B66FD8 /* TableViewCell.swift */; };
B1B679621D89807A00B66FD8 /* TableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B1B679601D89807A00B66FD8 /* TableViewCell.xib */; };
B1BAABCA22244B94007F0F61 /* ContactTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1BAABC822244B94007F0F61 /* ContactTableViewCell.swift */; };
Expand Down Expand Up @@ -128,6 +134,12 @@
B1AD7EC21D8C194400BFCA34 /* InputSwitchRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputSwitchRow.swift; sourceTree = "<group>"; };
B1AD7EC41D8C198F00BFCA34 /* InputSwitchViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputSwitchViewCell.swift; sourceTree = "<group>"; };
B1AD7EC51D8C198F00BFCA34 /* InputSwitchViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = InputSwitchViewCell.xib; sourceTree = "<group>"; };
B1B5C6D424FFCAAD00F05CE8 /* ScrollOffsetManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollOffsetManager.swift; sourceTree = "<group>"; };
B1B5C6D524FFCAAD00F05CE8 /* ScrollOffsetManagable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollOffsetManagable.swift; sourceTree = "<group>"; };
B1B5C6D824FFD6DB00F05CE8 /* CollectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionTableViewCell.swift; sourceTree = "<group>"; };
B1B5C6D924FFD6DB00F05CE8 /* CollectionTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollectionTableViewCell.xib; sourceTree = "<group>"; };
B1B5C6DC24FFDBED00F05CE8 /* CollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewCell.swift; sourceTree = "<group>"; };
B1B5C6DE24FFDC4000F05CE8 /* CollectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionRow.swift; sourceTree = "<group>"; };
B1B6795F1D89807A00B66FD8 /* TableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewCell.swift; sourceTree = "<group>"; };
B1B679601D89807A00B66FD8 /* TableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = TableViewCell.xib; sourceTree = "<group>"; };
B1BAABC822244B94007F0F61 /* ContactTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactTableViewCell.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -196,6 +208,7 @@
children = (
B11CEB79204977A2001308B3 /* CNContact+Row.swift */,
B1BAABCE22244C06007F0F61 /* CNContact+Section.swift */,
B1B5C6DE24FFDC4000F05CE8 /* CollectionRow.swift */,
);
path = Models;
sourceTree = "<group>";
Expand All @@ -205,6 +218,9 @@
children = (
B1BAABCC22244BAF007F0F61 /* ContactTableViewCell.xib */,
B1BAABC822244B94007F0F61 /* ContactTableViewCell.swift */,
B1B5C6D824FFD6DB00F05CE8 /* CollectionTableViewCell.swift */,
B1B5C6D924FFD6DB00F05CE8 /* CollectionTableViewCell.xib */,
B1B5C6DC24FFDBED00F05CE8 /* CollectionViewCell.swift */,
);
path = Cells;
sourceTree = "<group>";
Expand Down Expand Up @@ -248,6 +264,7 @@
B17BAA341D89639100844421 /* ThunderTable */ = {
isa = PBXGroup;
children = (
B1B5C6D324FFCA8C00F05CE8 /* Scroll Offset Caching */,
B1D10CD01DA540CF003FBCB4 /* Theme.swift */,
B19C53261D8B036600B30A35 /* ApplicationLoadingIndicatorManager.swift */,
B1E0883B1DA638B400E45E47 /* Images */,
Expand Down Expand Up @@ -305,6 +322,15 @@
name = Cells;
sourceTree = "<group>";
};
B1B5C6D324FFCA8C00F05CE8 /* Scroll Offset Caching */ = {
isa = PBXGroup;
children = (
B1B5C6D524FFCAAD00F05CE8 /* ScrollOffsetManagable.swift */,
B1B5C6D424FFCAAD00F05CE8 /* ScrollOffsetManager.swift */,
);
name = "Scroll Offset Caching";
sourceTree = "<group>";
};
B1E0883B1DA638B400E45E47 /* Images */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -454,6 +480,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B1B5C6DB24FFD6DB00F05CE8 /* CollectionTableViewCell.xib in Resources */,
B14F945B20444E820014F694 /* LaunchScreen.storyboard in Resources */,
B14F945820444E820014F694 /* Assets.xcassets in Resources */,
B1BAABCD22244BAF007F0F61 /* ContactTableViewCell.xib in Resources */,
Expand Down Expand Up @@ -495,7 +522,10 @@
buildActionMask = 2147483647;
files = (
B1BAABCA22244B94007F0F61 /* ContactTableViewCell.swift in Sources */,
B1B5C6DA24FFD6DB00F05CE8 /* CollectionTableViewCell.swift in Sources */,
B1B5C6DF24FFDC4000F05CE8 /* CollectionRow.swift in Sources */,
B1BAABCF22244C06007F0F61 /* CNContact+Section.swift in Sources */,
B1B5C6DD24FFDBED00F05CE8 /* CollectionViewCell.swift in Sources */,
B14F945320444E820014F694 /* ViewController.swift in Sources */,
B14F945120444E820014F694 /* AppDelegate.swift in Sources */,
B11CEB9F20498F0C001308B3 /* CNContact+Row.swift in Sources */,
Expand All @@ -511,6 +541,7 @@
B1784D831D8C3A60007358EA /* InputSliderRow.swift in Sources */,
B1C2C56C1FFCEE3100D968C5 /* InputDatePickerRow.swift in Sources */,
B1EC81021FDE86BF00C8EE72 /* SubtitleTableViewCell.swift in Sources */,
B1B5C6D724FFCAAD00F05CE8 /* ScrollOffsetManagable.swift in Sources */,
B1B679611D89807A00B66FD8 /* TableViewCell.swift in Sources */,
B1784D861D8C3A8A007358EA /* InputSliderViewCell.swift in Sources */,
B1C2C56F1FFCF20F00D968C5 /* InputDatePickerViewCell.swift in Sources */,
Expand All @@ -521,6 +552,7 @@
B114F10E2035CA75005D52F2 /* InputPickerRow.swift in Sources */,
B1EC81061FDE873700C8EE72 /* Value1TableViewCell.swift in Sources */,
B19C53241D8B033E00B30A35 /* InputTextFieldViewCell.swift in Sources */,
B1B5C6D624FFCAAD00F05CE8 /* ScrollOffsetManager.swift in Sources */,
B1EC80FE1FDE85EA00C8EE72 /* DefaultTableViewCell.swift in Sources */,
B1AD7EC61D8C198F00BFCA34 /* InputSwitchViewCell.swift in Sources */,
B1E0883D1DA638CE00E45E47 /* ImageView.swift in Sources */,
Expand Down
13 changes: 13 additions & 0 deletions ThunderTable/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@

import Foundation

extension Array where Element == Section {

/// Returns the full set of index paths that cover the array of sections
var indexPaths: [IndexPath] {
return enumerated().map { (offset, element) -> [IndexPath] in
let rows = element.rows
return (0..<rows.count).map { (row) -> IndexPath in
return IndexPath(row: row, section: offset)
}
}.flatMap({ $0 })
}
}

extension Array : Section {

public var rows: [Row] {
Expand Down
35 changes: 35 additions & 0 deletions ThunderTable/ScrollOffsetManagable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// ScrollDelegatable.swift
// ThunderTable
//
// Created by Simon Mitchell on 02/09/2020.
// Copyright © 2020 threesidedcube. All rights reserved.
//

import UIKit

/// A simpler version of `UIScrollViewDelegate` so we don't intefere
/// with users `UIScrollViewDelegate` implementations
public protocol ScrollOffsetDelegate: class {

/// Called when the content offset of the scroll view changes due to `scrollViewDidScroll`
/// - Parameters:
/// - scrollable: The scrollable that the change was for
func scrollViewDidChangeContentOffset(_ scrollable: ScrollOffsetManagable)
}

/// A protocol implemented to allow control and listening to scroll view offset changes
/// by `ScrollOfffsetManager`
public protocol ScrollOffsetManagable: class {

/// The scroll view that is controllable on the object
var scrollView: UIScrollView? { get }

/// The delegate to have scroll view delegate methods passed to, this should be a `weak`
/// property to avoid retain cycles.
/// - Warning: It is your job to call the method on this delegate from your scrollViewDidScroll method.
var scrollDelegate: ScrollOffsetDelegate? { get set }

/// An identifier used by `ScrollOffsetManager`
var identifier: AnyHashable? { get set }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can defo see why if the implementor providing an identifier is real nice. Hence this. But it's a little annoying to have to provide it. Maybe I'm overlooking here, how's this going to work if some provide it and some don't?

Anyway we probably want some default implementations here? :) Like this and delegate as nil?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So TableViewController actually sets this to the given IndexPath so all implementers have to do is provide the property. Not sure if that makes sense to you? It just means all information is stored on the cell itself, and we don't need to fetch different bits of info from different places. Maybe I need to make that clearer in the docs?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well Apple choose not to put an IndexPath property on the UITableViewCell. That's where the manager would hold onto that mapping. I'll come back to

}
75 changes: 75 additions & 0 deletions ThunderTable/ScrollOffsetManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// ScrollOffsetManager.swift
// ThunderTable
//
// Created by Simon Mitchell on 02/09/2020.
// Copyright © 2020 threesidedcube. All rights reserved.
//

import CoreGraphics
import Foundation

/// Scroll offset manager manages caching scroll offsets for UI in any scenario where
/// scroll views may be re-used and so we need to store their current offset in memory
/// to avoid re-use issues.
class ScrollOffsetManager {

private var offsetMap: [AnyHashable : CGPoint] = [:]
BenShutt marked this conversation as resolved.
Show resolved Hide resolved

/// Registers the scrollable to the manager for listening for scroll offset changes
/// - Parameters:
/// - scrollable: The scrollable to register
func register(
scrollable: ScrollOffsetManagable
) {
scrollable.scrollDelegate = self
}

/// Caches the offset for the given scrollable
/// - Parameters:
/// - scrollable: The scrollable to update the content offset for
func updateCachedOffset(scrollable: ScrollOffsetManagable) {
guard let identifier = scrollable.identifier else { return }
offsetMap[identifier] = scrollable.scrollView?.contentOffset
}

/// Sets the content offset on the given scrollable from the internal cache
/// - Parameters:
/// - scrollable: The scrollable to adjust the content offset on
/// - animated: Whether the transition should animate
/// - fallback: A fallback to set the content offset to if there is no cached value
func setScrollOffset(
_ scrollable: ScrollOffsetManagable,
animated: Bool = false,
fallback: CGPoint? = nil
) {
guard let identifier = scrollable.identifier else { return }
guard let newOffset = offsetMap[identifier] ?? fallback else { return }

guard let scrollView = scrollable.scrollView else { return }

// Disable then re-enable scroll indicators otherwise calling setContentOffset flashes the scroll indicators
let preShowVerticalScrollIndicator = scrollView.showsVerticalScrollIndicator
let preShowHorizontalScrollIndicator = scrollView.showsHorizontalScrollIndicator

scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false

scrollable.scrollView?.setContentOffset(newOffset, animated: animated)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollView was unwrapped above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! 🤦


scrollView.showsVerticalScrollIndicator = preShowVerticalScrollIndicator
scrollView.showsHorizontalScrollIndicator = preShowHorizontalScrollIndicator
}

/// Resets all content offsets by removing them from the offset map
func resetAllOffsets() {
offsetMap = [:]
}
}

extension ScrollOffsetManager: ScrollOffsetDelegate {

func scrollViewDidChangeContentOffset(_ scrollable: ScrollOffsetManagable) {
updateCachedOffset(scrollable: scrollable)
}
}
57 changes: 55 additions & 2 deletions ThunderTable/TableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,19 @@ open class TableViewController: UITableViewController, UIContentSizeCategoryAdju

open var data: [Section] {
set {
// If the table view has had rows removed/added
BenShutt marked this conversation as resolved.
Show resolved Hide resolved
if newValue.indexPaths != data.indexPaths {
resetEmbeddedScrollOffsets()
}
_data = newValue
tableView.reloadData()
}
get {
return _data
}
}


/// The currently selected index path of the table view
public var selectedIndexPath: IndexPath?

public var selectedRows: [Row]? {
Expand Down Expand Up @@ -326,6 +331,8 @@ open class TableViewController: UITableViewController, UIContentSizeCategoryAdju

textLabel?.paragraphStyle = ThemeManager.shared.theme.cellTitleParagraphStyle
detailLabel?.paragraphStyle = ThemeManager.shared.theme.cellDetailParagraphStyle

updateScrollPosition(cell: cell, at: indexPath)

row.configure(cell: cell, at: indexPath, in: self)
}
Expand Down Expand Up @@ -570,7 +577,53 @@ open class TableViewController: UITableViewController, UIContentSizeCategoryAdju
set(indexPath: indexPath, selected: false)
}
}


//MARK: -
//MARK: Scroll Offset Management
//MARK:

private lazy var scrollOffsetManager: ScrollOffsetManager = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to you here, there's nothing wrong with doing it with = { }() in case you add properties in the future. But atm could simply do private let scrollOffsetManager = ScrollOffsetManager()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah let's go with that. It's memory footprint should be tiny anyways!

ScrollOffsetManager()
}()

/// Whether the table view should keep track of scroll view positions within it's cells.
/// This allows us to prevent re-use issues with re-using cells that the user has scrolled.
/// To allow your cell's scroll position to be remembered you need to implement the `ScrollOffsetManagable`
/// protocol on your `UITableViewCell` subclass.
/// - Note: This will not handle re-ordering of cells at present, and if the length of your
/// data changes (different numbers of rows in any section, or different number of sections)
/// then the cached values will be reset. This may be improved in future if we decide to enforce
/// row's being `Equatable`.
public var rememberEmbeddedScrollPositions: Bool = true

/// Resets all the "remembered" scroll offsets back to `.zero` for all embedded scrollable cells
/// that conform to `ScrollOffsetManagable`.
///
/// - Note: This will not actually perform any scrolling on the visible cells (Based on the value provided in `scrollVisibleCells`,
/// but they will reset to `.zero` the next time that `cellForRow:` is called regardless of the value provided.
/// - Parameter scrollVisibleCells: Whether to scroll the visible cells as well as resetting the cache. Defaults to `false`
/// - Parameter animated: If `scrollVisibleCells == true`, whether we should animate the transition. Defaults to `false`
public func resetEmbeddedScrollOffsets(scrollingVisibleCells: Bool = false, animated: Bool = false) {
scrollOffsetManager.resetAllOffsets()
guard scrollingVisibleCells else { return }
tableView.visibleCells.forEach { (cell) in
guard let scrollable = cell as? ScrollOffsetManagable else { return }
scrollable.scrollView?.setContentOffset(.zero, animated: animated)
}
}

func updateScrollPosition(cell: UITableViewCell, at indexPath: IndexPath) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No harm in an animated parameter? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done... and documented!


guard rememberEmbeddedScrollPositions, let scrollable = cell as? ScrollOffsetManagable else { return }

// Set the identifier on the scrollable so it can be tracked. Use `indexPath` for this
scrollable.identifier = indexPath
// Register the scrollable so it's offset is tracked
scrollOffsetManager.register(scrollable: scrollable)
// Set the scroll offset based on `scrollOffsetManager`. This fixes scroll re-use issues.
scrollOffsetManager.setScrollOffset(scrollable, animated: false, fallback: .zero)
}

//MARK - variable header/footer size

private var headerTranslatesAutoResizingMask: Bool = false
Expand Down
33 changes: 33 additions & 0 deletions ThunderTableDemo/Cells/CollectionTableViewCell.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// CollectionTableViewCell.swift
// ThunderTableDemo
//
// Created by Simon Mitchell on 02/09/2020.
// Copyright © 2020 3SidedCube. All rights reserved.
//

import UIKit
import ThunderTable

class CollectionTableViewCell: UITableViewCell, ScrollOffsetManagable, UICollectionViewDelegate {

var scrollView: UIScrollView? {
return collectionView
}

weak var scrollDelegate: ScrollOffsetDelegate?

var identifier: AnyHashable?

@IBOutlet weak var collectionView: UICollectionView!

override func awakeFromNib() {
super.awakeFromNib()
collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
collectionView.delegate = self
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollDelegate?.scrollViewDidChangeContentOffset(self)
}
}