【iOS】ViewControllerを汚さずにUITableViewの下部からCellを追加する実装

  • 34
    いいね
  • 0
    コメント

はじめに

メッセージアプリで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.frameworkLinked Frameworks and Librariescopy-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というプロパティを介して拡張したメソッドなどにアクセスできるようになっています。
ReverseExtensionreはRxSwiftのrxに近い形ではあるのですが、ReactiveCompatibleの中ではrxにアクセスする度にReactive(self)が返されているので、新しいインスタンスが返されることになります。しかし、ReverseExtensionreは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を複数のオブジェクトにメッセージ転送

ReverseExtensionre.delegateにdelegate先を指定した場合、指定先のオブジェクトでもdelegateの処理を実行しつつ、ReverseExtensionの中でもdelegateの処理を実行しています。

DelegateTransporter

複数のオブジェクトでdelegateの処理を実行するために、DelegateTransporter利用してメッセージ転送を行っています。
forwardInvocationがSwiftで利用できないため、Objective-Cで実装されています。

DelegateTransporterUITableViewDelegateTransporterのような形で継承して使われるそうていなので、イニシャライザはNS_REFINED_FOR_SWIFTを使ってSwiftに適した形で書き換えができるようにしてあります。

DelegateTransporter.h
@interface DelegateTransporter : NSObject
- (nonnull instancetype)initWithDelegates:(NSArray<id> * __nonnull)delegates NS_REFINED_FOR_SWIFT;
@end

DelegateTransporterでは複数のdelegate元を保持するのでうが、強参照で保持してしまうとオブジェクトがリリースされなくなってしまうので、[NSHashTable weakObjectsHashTable]で弱参照で保持するようになっています。

DelegateTransporter.m
#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

UITableViewDelegateTransporterUITableViewDelegateを採用したクラスとして定義します。
このようにするとこで、tableView.delegate = UITableViewDelegateTransporter(deleagetes: [self, deleagete])のようにUITableViewDelegateTransporterUITableViewDelegateとして扱うことができるようになります。

UITableViewDelegateTransporter.swift
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を追加する処理を簡単に実装することができるようになります。