Swift

ScrollによるPaginationの実装

More than 3 years have passed since last update.

研修課題の終盤に差し掛かりました。

前回の続きです。

Alamofire,SwiftyJSON,ModelでCellをカスタマイズしてTableViewを描画する


要件:

①APIのdataをTableViewのCellに描画する

②APIのdataは、nページある

③scrollによりiページ目のdataが描画し終わり、新たにi+1ページのdataが必要な時に通信を行い、それをcellに描画する

④scrollをトリガーにAPIへ通信するが、APIへ通信している時にscrollしても新たにAPIへ通信しないように制御する

⑤APIの最後のページであるindexがある前提で(今回はboolean型変数のisLastを用いている。最後のページのみtrue、他はfalse)、最後のページも読んだらscrollしてもAPIへ通信しないように制御する


以下補足。

ここで実装する手順を考える前に、③をもう少し掘り下げてみます。

まずscrollをしたことを感知する関数として、scrollViewDidScrollという関数がUIScrollViewクラスには備わっているので、scroll感知にはこれを使います。

次に、scrollをしていき、i+1ページのdataが必要な時がいつか(すなわち、scrollをどれくらいしたらAPIへ通信をするのか)を定義してあげる必要があります。

④についてはやってみればわかりますが、APIへの通信は少し時間がかかります。

APIへ通信をしている間に通信の制御をせず、scrollを続けると何が起きるのかというと、iページのdataを読み込んでいる時にscrollを感知したことによりiページへの通信が複数回、同時並行で呼ばれてしまいます。

こうしたことを防ぐために、APIへ通信を始めたらそのページへの通信はシャットアウトする条件が必要で、これを通信の制御と私は表現しています。


設計:

(a)通信をしている状態としていない状態の状態推移を追うenum、loadingStateを定義。(④に対応)

(b)③の「新たにi+1ページのdataが必要な時」とは、「iページ目のdataが初期位置からscrollされて残りが少なくなった時(私はy方向の高さが120を切ったら、という条件にしました)」と定義して評価するようにする。(③に対応)

(c)APIへのrequestをする関数を作成して、適切な時、すなわち起動時と、scrollにより③が発生した時にcallをする。この際、通信している時はlodingStateを.InProgress、APIからdataを抜き取り、インスタンス化し切ったらlodingStateを.Noneにswitchして次に読むページをincrementする。(③と④に対応)

なお、requestをする関数を作成するのはviewDidLoadは一回しか呼ばれないから。


実装例:


ProfileListViewController.swift

import UIKit

import Alamofire
import SwiftyJSON

class ProfileListViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

@IBOutlet weak var tableView: UITableView!
var profileCells: [ProfileListItemTableViewCellModel] = [ProfileListItemTableViewCellModel]()
var currentPage : Int = 0
var isLast: Bool = false
// (a)状態推移を担うenum、LoadingState型は下記参照
var loadingState: LoadingState = .None
var requestable: Bool {
return loadingState == .None && !isLast
}

override func viewDidLoad() {
super.viewDidLoad()

tableView.delegate = self
tableView.dataSource = self

// (c)起動時にAPIへ通信している
self.requestObjects()
}

// (c)APIへのgetrequest関数
func requestObjects(refresh: Bool=false) {
if requestable == false { return }

// loading data from the currentPage in the API
self.loadingState = .InProgress

// address to the API
let userID: Int = 1
let params = ["page": "\(currentPage)"]
let request = APIRequestManager.getRequest("v123456", urlString: "/users/\(userID)/notifications", parameters: params, accessToken: true) //APIRequestManagerは下記参照

// call getRequest to the API
// SwiftTaskを用いています
let task = APICollectionRequestTask<ProfileListItemTableViewCellModel>(request: request).value
task.progress { progress in
}.success { models in
self.loadingState = .None
++self.currentPage

// add the objective data from the API to array and assign the data "isLast" from the API to the local variable "isLast"
for model in models {
self.profileCells.append(model)
self.isLast = models.last!.isLast
}
self.tableView.reloadData()
}.failure{ status in
// error handling
// user alert
self.loadingState = .None
print("failure:\(status)")
}
}

//省略

extension ProfileListViewController {
func scrollViewDidScroll(scrollView: UIScrollView) {
// if the value of the variable "isLast" in called API equals true, willPaginate is not implemented.
// (c)scrollによるAPIへのgetrequest
TableView.willPaginate(scrollView, scrollable: self.requestable, completion: {
self.requestObjects()
})
}
}



LoadingState.swift

public enum LoadingState {

case None
case InProgress
case Success
case Failure
}


APIRequestManager.swift

import Alamofire

typealias RequestParamaters = Dictionary<String, AnyObject>?

final class APIRequestManager: NSObject {

class func getRequest(version: String, urlString: String, var parameters: RequestParamaters=nil, accessToken: Bool=false) -> Request {
parameters = setAccessToken(parameters, accessToken: accessToken)
return Alamofire.request(.GET, BaseURLString + version + urlString, parameters: parameters).validate()
}
}



APICollectionRequestTask.swift

import Alamofire

import SwiftTask

final class APICollectionRequestTask<ResponseObject: ResponseCollectionSerializable> {
typealias Progress = (bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64)
typealias CollectionRequestTask = Task<Progress, [ResponseObject], Int?>
let value: CollectionRequestTask

init(request: Request) {
self.value = CollectionRequestTask { progress, fulfill, reject, configure in

request.progress { bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in
progress((bytesWritten, totalBytesWritten, totalBytesExpectedToWrite) as Progress)
}

request.responseCollection { (_, response, result: Result<[ResponseObject]>) in
switch result {
case .Success(let objects):
fulfill(objects)
case .Failure:
reject(response?.statusCode)
}
}
}
}

deinit {
debugPrint("deinit: APICollectionRequestTask")
}
}



TableView.swift

import UIKit

class TableView {

//(b)の実装
class func willPaginate(scrollView: UIScrollView, scrollable: Bool, completion: () -> Void) {
if scrollView.contentSize.height == 0 || scrollable == false { return }
let rest_scroll_height = (scrollView.contentSize.height + scrollView.contentInset.bottom) - (scrollView.contentOffset.y + scrollView.frame.size.height)
if (rest_scroll_height < 120.0) {
completion()
}
}
}


import Foundation

import SwiftyJSON

final class ProfileListItemTableViewCellModel: NSObject, ResponseCollectionSerializable {
var imageUrl: String = ""
var intro: String = ""
var name: String = ""
var isLast: Bool = false

init? (object: JSON) {
self.imageUrl = object["image"]["thumb"]["url"].stringValue
self.intro = object["message"].stringValue
self.name = object["name"].stringValue
}

class func collection(object: JSON) -> [ProfileListItemTableViewCellModel] {
let json: [JSON] = object["notifications"].array!
var profileCells = [ProfileListItemTableViewCellModel]()
for item in json {
profileCells.append(ProfileListItemTableViewCellModel(object: item)!)
}
profileCells.last?.isLast = object["is_last"].boolValue
return profileCells
}
}

import UIKit

class ProfileListItemTableViewCell: UITableViewCell {

@IBOutlet weak var profileImage1: UIImageView!
@IBOutlet weak var profileName1: UILabel!
@IBOutlet weak var profileIntro1: UILabel!
@IBOutlet weak var profileImage2: UIImageView!
@IBOutlet weak var profileName2: UILabel!
@IBOutlet weak var profileIntro2: UILabel!

override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}

override func setSelected(selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)

// Configure the view for the selected state
}

// initialize the odd-numbered cell object made from ProfileListItemTableViewCellModel
func setCell1(cellobject: ProfileListItemTableViewCellModel, atIndexPath indexPath: NSIndexPath) {
let imageView = profileImage1 as UIImageView
imageView.setUserAvatar(cellobject.imageUrl)
profileName1.text = cellobject.name
profileIntro1.text = cellobject.intro
}

// initialize the even-numbered cell object made from ProfileListItemTableViewCellModel
func setCell2(cellobject: ProfileListItemTableViewCellModel, atIndexPath indexPath: NSIndexPath) {
let imageView = profileImage2 as UIImageView
imageView.setUserAvatar(cellobject.imageUrl)
profileName2.text = cellobject.name
profileIntro2.text = cellobject.intro
}
}


APIResponseObjectSerializable.swift

import Alamofire

import SwiftyJSON

public protocol ResponseCollectionSerializable {
static func collection(object: JSON) -> [Self]
}


SwiftTaskについては追記します。

参考になれば幸いです。