はじめに
メッセージアプリでUITableViewにCellが追加される際に、下図のようにUITableViewの上部からCellが埋まっていき、下に追加されていくものが多いかと思います。
しかし、本来実現したいインタラクションとしては、下図のようにUITableViewの下部からCellが埋まっていくものではないでしょうか?
このインタラクションを簡単に実現できるものが ReverseExtension (558Star 2017/03/21)になります。
それでは、下部からCellを追加していく実装を解説していきたいと思います。
インストール方法
CocoaPodsまたはCarthageからインストールすることができます。
CocoaPods
Podfileに下記を追加し、pod update
を実行してください。
pod "ReverseExtension"
Carthage
Cartfileに下記を追加し、carthage update
を実行してください。
github "marty-suzuki/ReverseExtension"
ReverseExtension.framework
をLinked Frameworks and Librariesとcopy-frameworksに追加してください。
実装方法
ReverseExtensionは、re
というプロパティの中でインタラクションなどの処理をラップしているので
tableView.re.dataSource = self
tableView.re.deleagate = self
のように、既存のUITableViewに対してre
を追加するだけで実行することができるようになるという特徴があります。
実際にViewControllerでReverseExtensionを使うと、下記のような実装になります。
import ReverseExtension
class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
fileprivate var messages: [MessageModel] = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "Cell")
//reでdataSourceとdelegateを指定
tableView.re.dataSource = self
tableView.re.delegate = self
tableView.estimatedRowHeight = 56
tableView.rowHeight = UITableViewAutomaticDimension
}
@IBAction func addButtonTapped(_ sender: UIBarButtonItem) {
messages.append(MessageModel())
tableView.beginUpdates()
//reでinsert
tableView.re.insertRows(at: [IndexPath(row: messages.count - 1, section: 0)], with: .automatic)
tableView.endUpdates()
}
@IBAction func trashButtonTapped(_ sender: UIBarButtonItem) {
messages.removeAll()
tableView.reloadData()
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
(cell as? TableViewCell)?.configure(with: messages[indexPath.row])
return cell
}
}
extension ViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
//UITableViewDelegateのハンドリングも可能
}
}
また下部からCellを追加する処理を無くしたい場合は、re
を無くすだけで標準のUITableViewの挙動に戻るようになります。
カスタムプロパティ
メッセージなどをリスト表示する際に、現在取得できているものよりも最新(または過去)のものを取得するという処理を実装するかと思います。
それらの処理の発火タイミングは、UITableViewのcontentOffsetが最下部(または最上部)に達したときになるかと思います。
ReverseExtensionではそれらのイベントを受け取れるように
tableView.re.scrollViewDidReachTop
tableView.re.scrollViewDidReachBottom
のクロージャーが利用できるようになっています。
class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
tableView.re.delegate = self
tableView.re.scrollViewDidReachTop = { scrollView in
print("contentOffsetが最上部に達したイベントを取得")
}
tableView.re.scrollViewDidReachBottom = { scrollView in
print("contentOffsetが最下部に達したイベントを取得")
}
}
}
re
とは
ReverseExtensionでは、ViewControllerでの実装が肥大化しないようにするため、ReverseExtension classの中でUITableViewDeleagateが実装されていて、そのメソッドを利用して反転処理などが行われています。
RxSwiftではReactiveCompatibleを採用しているクラスで、rx
というプロパティを介して拡張したメソッドなどにアクセスできるようになっています。
ReverseExtensionのre
はRxSwiftのrx
に近い形ではあるのですが、ReactiveCompatibleの中ではrx
にアクセスする度にReactive(self)
が返されているので、新しいインスタンスが返されることになります。しかし、ReverseExtensionのre
はdelegateやdataSourceを保持するために下記のようにAssociatedObject
としてインスタンスが保持されています。
extension UITableView {
private struct AssociatedKey {
static var re: UInt8 = 0
}
public var re: ReverseExtension {
guard let re = objc_getAssociatedObject(self, &AssociatedKey.re) as? ReverseExtension else {
let re = ReverseExtension(self)
objc_setAssociatedObject(self, &AssociatedKey.re, re, .OBJC_ASSOCIATION_RETAIN)
return re
}
return re
}
}
Delegateを複数のオブジェクトにメッセージ転送
ReverseExtensionのre.delegate
にdelegate先を指定した場合、指定先のオブジェクトでもdelegateの処理を実行しつつ、ReverseExtensionの中でもdelegateの処理を実行しています。
DelegateTransporter
複数のオブジェクトでdelegateの処理を実行するために、DelegateTransporter
利用してメッセージ転送を行っています。
forwardInvocation
がSwiftで利用できないため、Objective-Cで実装されています。
DelegateTransporter
はUITableViewDelegateTransporter
のような形で継承して使われるそうていなので、イニシャライザはNS_REFINED_FOR_SWIFT
を使ってSwiftに適した形で書き換えができるようにしてあります。
@interface DelegateTransporter : NSObject
- (nonnull instancetype)initWithDelegates:(NSArray<id> * __nonnull)delegates NS_REFINED_FOR_SWIFT;
@end
DelegateTransporter
では複数のdelegate元を保持するのでうが、強参照で保持してしまうとオブジェクトがリリースされなくなってしまうので、[NSHashTable weakObjectsHashTable]
で弱参照で保持するようになっています。
# import "DelegateTransporter.h"
@interface DelegateTransporter ()
@property (nonnull, nonatomic, strong) NSHashTable<NSObject *> *delegates;
@end
@implementation DelegateTransporter
- (instancetype)initWithDelegates:(NSArray<id> *)delegates {
self = [super init];
if (self) {
self.delegates = [NSHashTable weakObjectsHashTable];
for (id delegate in delegates) {
if (![delegate isKindOfClass:[NSObject class]]) { continue; }
[self.delegates addObject: delegate];
}
}
return self;
}
- (BOOL)respondsToSelector:(SEL)aSelector {
for (NSObject *delegate in self.delegates) {
if ([delegate isKindOfClass:[NSNull class]]) {
continue;
}
if ([delegate respondsToSelector:aSelector]) {
return YES;
}
}
return NO;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
for (NSObject *delegate in self.delegates) {
if ([delegate isKindOfClass:[NSNull class]]) {
continue;
}
if ([delegate respondsToSelector:aSelector]) {
return [delegate methodSignatureForSelector:aSelector];
}
}
return nil;
}
- (void)forwardInvocation:(NSInvocation *)anInvocation {
for (NSObject *delegate in self.delegates) {
if ([delegate isKindOfClass:[NSNull class]]) {
continue;
}
if ([delegate respondsToSelector:anInvocation.selector]) {
[anInvocation invokeWithTarget:delegate];
}
}
}
@end
UITableViewDelegateTransporter
UITableViewDelegateTransporter
はUITableViewDelegate
を採用したクラスとして定義します。
このようにするとこで、tableView.delegate = UITableViewDelegateTransporter(deleagetes: [self, deleagete])
のようにUITableViewDelegateTransporter
をUITableViewDelegate
として扱うことができるようになります。
class UITableViewDelegateTransporter: DelegateTransporter, UITableViewDelegate {
@nonobjc convenience init(delegates: [UITableViewDelegate]) {
self.init(__delegates: delegates)
}
}
ReverseExtensionで独自実装を内包する
上記で実装したUITableViewDelegateTransporter
を利用して、反転処理などの独自実装を内包します。
extension UITableView.ReverseExtension: UITableViewDelegate {
public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if cell.contentView.transform == CGAffineTransform.identity {
UIView.setAnimationsEnabled(false)
cell.contentView.transform = CGAffineTransform.identity.rotated(by: .pi)
UIView.setAnimationsEnabled(true)
}
}
}
最後に
このようにして、UITableViewのインスタンスにre
を追加するだけで、UITableViewの下部からCellを追加する処理を簡単に実装することができるようになります。