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

ページネーション処理を追加 #6

Merged
merged 7 commits into from
Feb 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion iOSEngineerCodeCheck/Sources/API/GitHubAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,16 @@ struct GitHubAPI {
let path: String = "/search/repositories"

var parameters: Any? {
return ["q": query]
return [
"q": query,
"page": page,
"per_page": perPage
]
}

let query: String
let page: Int
let perPage: Int = 20

func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
return try (object as? Response) ??
Expand Down
9 changes: 8 additions & 1 deletion iOSEngineerCodeCheck/Sources/Common/Models/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct Repository: Decodable {
let watchersCount: Int
let forksCount: Int
let openIssuesCount: Int
let description: String
let description: String?

let owner: Owner

Expand All @@ -36,8 +36,15 @@ struct Repository: Decodable {
}
}

extension Repository: Equatable {
static func ==(lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
}
}

extension Repository {
var languageColor: UIColor? {
return language.flatMap(Language.init(rawValue:))?.color
}
}

27 changes: 14 additions & 13 deletions iOSEngineerCodeCheck/Sources/Views/Search/Cell/RepositoryCell.xib
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="125" id="KGk-i7-Jjw" customClass="RepositoryCell" customModule="iOSEngineerCodeCheck" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="252" height="130"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="132" id="KGk-i7-Jjw" customClass="RepositoryCell" customModule="iOSEngineerCodeCheck" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="249" height="133"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="252" height="130"/>
<rect key="frame" x="0.0" y="0.0" width="249" height="133"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="suM-tV-HAI">
Expand All @@ -26,66 +26,67 @@
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bCh-CS-e2g">
<rect key="frame" x="56" y="17" width="188" height="14.5"/>
<rect key="frame" x="56" y="17" width="185" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZgP-Q8-kAH">
<rect key="frame" x="16" y="48" width="228" height="14.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="12"/>
<rect key="frame" x="16" y="48" width="225" height="17"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4bF-X4-p0h">
<rect key="frame" x="16" y="70.5" width="228" height="19.5"/>
<rect key="frame" x="16" y="73" width="225" height="19.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="B6L-e9-q3A">
<rect key="frame" x="18" y="98" width="16" height="16"/>
<rect key="frame" x="18" y="101" width="16" height="16"/>
<constraints>
<constraint firstAttribute="width" constant="16" id="cwM-2T-cLc"/>
<constraint firstAttribute="height" constant="16" id="eDy-Vh-Dxd"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="POI-PY-sVe">
<rect key="frame" x="42" y="99" width="31" height="14.5"/>
<rect key="frame" x="42" y="102" width="31" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="i5t-jH-jad">
<rect key="frame" x="89" y="98" width="16" height="16"/>
<rect key="frame" x="89" y="101" width="16" height="16"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="width" constant="16" id="0JX-Mo-k1c"/>
<constraint firstAttribute="height" constant="16" id="ida-1Q-YVg"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wJt-K2-C9x">
<rect key="frame" x="113" y="99" width="31" height="14.5"/>
<rect key="frame" x="113" y="102" width="31" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="B6L-e9-q3A" firstAttribute="top" secondItem="4bF-X4-p0h" secondAttribute="bottom" constant="8.5" id="0eB-0V-mbe"/>
<constraint firstItem="ZgP-Q8-kAH" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="2L0-nt-HFG"/>
<constraint firstItem="bCh-CS-e2g" firstAttribute="centerY" secondItem="suM-tV-HAI" secondAttribute="centerY" id="2qd-lP-Fby"/>
<constraint firstItem="suM-tV-HAI" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="976-fd-NFs"/>
<constraint firstItem="4bF-X4-p0h" firstAttribute="top" secondItem="ZgP-Q8-kAH" secondAttribute="bottom" constant="8" id="Ay2-zs-Xx6"/>
<constraint firstItem="suM-tV-HAI" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="CZH-c7-EvE"/>
<constraint firstItem="POI-PY-sVe" firstAttribute="leading" secondItem="B6L-e9-q3A" secondAttribute="trailing" constant="8" id="FmQ-F8-emf"/>
<constraint firstItem="4bF-X4-p0h" firstAttribute="leading" secondItem="B6L-e9-q3A" secondAttribute="trailing" constant="-18" id="Kgi-PD-ISx"/>
<constraint firstItem="i5t-jH-jad" firstAttribute="leading" secondItem="POI-PY-sVe" secondAttribute="trailing" constant="16" id="NRh-VW-Kp7"/>
<constraint firstAttribute="trailing" secondItem="bCh-CS-e2g" secondAttribute="trailing" constant="8" id="PMf-WP-xdU"/>
<constraint firstItem="POI-PY-sVe" firstAttribute="centerY" secondItem="B6L-e9-q3A" secondAttribute="centerY" id="QHU-qB-pG1"/>
<constraint firstItem="wJt-K2-C9x" firstAttribute="leading" secondItem="i5t-jH-jad" secondAttribute="trailing" constant="8" id="ReH-0n-4ga"/>
<constraint firstItem="4bF-X4-p0h" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="Tqc-B2-iFy"/>
<constraint firstItem="i5t-jH-jad" firstAttribute="centerY" secondItem="POI-PY-sVe" secondAttribute="centerY" id="UmK-v2-qoj"/>
<constraint firstAttribute="bottom" secondItem="B6L-e9-q3A" secondAttribute="bottom" constant="16" id="VPO-u9-rlj"/>
<constraint firstItem="B6L-e9-q3A" firstAttribute="top" secondItem="4bF-X4-p0h" secondAttribute="bottom" constant="8" id="VPs-bw-KUP"/>
<constraint firstItem="ZgP-Q8-kAH" firstAttribute="top" secondItem="suM-tV-HAI" secondAttribute="bottom" constant="8" id="Xgl-B3-oXI"/>
<constraint firstItem="wJt-K2-C9x" firstAttribute="centerY" secondItem="i5t-jH-jad" secondAttribute="centerY" id="etd-hc-MVg"/>
<constraint firstAttribute="trailing" secondItem="4bF-X4-p0h" secondAttribute="trailing" constant="8" id="oOr-Hb-DbP"/>
Expand All @@ -105,7 +106,7 @@
<outlet property="starCountLabel" destination="POI-PY-sVe" id="Vez-fU-Mwk"/>
<outlet property="starImageView" destination="B6L-e9-q3A" id="XRR-LI-vSb"/>
</connections>
<point key="canvasLocation" x="-208.69565217391306" y="97.098214285714278"/>
<point key="canvasLocation" x="-210.86956521739131" y="98.102678571428569"/>
</tableViewCell>
</objects>
<resources>
Expand Down
42 changes: 35 additions & 7 deletions iOSEngineerCodeCheck/Sources/Views/Search/SearchModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,48 @@ import APIKit

protocol SearchModelProtocol {
var delegate: SearchModelDelegate? { get set }
var repositories: [Repository] { get }
var sessionTask: SessionTask? { get }
func fetchItems(text: String)
func fetchAdditionalRepositories()
func fetchInitialRepositories(query: String)
func cancel()
}

class SearchModel: SearchModelProtocol {
weak var delegate: SearchModelDelegate?
private(set) var sessionTask: SessionTask?
internal var sessionTask: SessionTask?
private var currentPage: Int = 1
private var currentQuery: String = ""

func fetchItems(text: String) {
self.sessionTask = GitHubAPI.call(request: GitHubAPI.SearchRepositories(query: text)) { [weak self] (result) in
private(set) var repositories: [Repository] = [] {
didSet {
if oldValue != repositories {
self.delegate?.didChange(repositories: repositories)
}
}
}

func fetchAdditionalRepositories() {
fetchRepositories(query: self.currentQuery, page: currentPage + 1) { [weak self] (response) in
self?.repositories += response.repositories
}
}

func fetchInitialRepositories(query: String) {
self.currentQuery = query
self.currentPage = 1
fetchRepositories(query: query, page: self.currentPage) { [weak self] response in
self?.repositories = response.repositories
}
}

private func fetchRepositories(query: String, page: Int, completion: @escaping (SearchResponse) -> Void) {
let request = GitHubAPI.SearchRepositories(query: query, page: page)
self.sessionTask = GitHubAPI.call(request: request) { [weak self] (result) in
switch result {
case let .success(response):
self?.delegate?.didChange(repositories: response.repositories)
case let.success(response):
self?.currentPage += 1
completion(response)
case let .failure(error):
self?.delegate?.didReceive(error: error)
}
Expand All @@ -37,5 +65,5 @@ class SearchModel: SearchModelProtocol {

protocol SearchModelDelegate: class {
func didChange(repositories: [Repository])
func didReceive(error: Error)
func didReceive(error: SessionTaskError)
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class SearchViewController: UITableViewController, StoryboardInstantiatable, Inj
let detailVC = DetailViewController.instantiate(with: item)
navigationController?.pushViewController(detailVC, animated: true)
}

override func scrollViewDidScroll(_ scrollView: UIScrollView) {
let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height)
presenter.setIsReachedBottom(maxScrollDistance <= scrollView.contentOffset.y)
}
}

extension SearchViewController: SearchView {
Expand All @@ -65,8 +70,12 @@ extension SearchViewController: SearchView {
}

func showAlert(with errorMessage: String) {
//TODO: show alert
print(errorMessage)
let alert = UIAlertController(title: errorMessage,
message: errorMessage,
preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default){ _ in }
alert.addAction(okAction)
present(alert, animated: false, completion: nil)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,55 @@
//

import Foundation
import APIKit

protocol SearchPresenter {
var view: SearchView? { get set }
var repositories: [Repository] { get }
func searchButtonTapped(with text: String)
func textDidChange()
func setIsReachedBottom(_ isReachedBottom: Bool)
}

class SearchViewPresenter: SearchPresenter {

weak var view: SearchView?
private(set) var repositories: [Repository] = []
var repositories: [Repository] {
return model.repositories
}
private var model: SearchModelProtocol
private var isReachedBottom: Bool = false

init(model: SearchModelProtocol) {
self.model = model
self.model.delegate = self
}

func searchButtonTapped(with text: String) {
guard text.count != 0 else { return }
model.fetchItems(text: text)
func searchButtonTapped(with query: String) {
guard query.count != 0 else { return }
model.fetchInitialRepositories(query: query)
}

func textDidChange() {
model.cancel()
}

func setIsReachedBottom(_ isReachedBottom: Bool) {
if !self.isReachedBottom && isReachedBottom {
model.fetchAdditionalRepositories()
}
self.isReachedBottom = isReachedBottom
}
}

extension SearchViewPresenter: SearchModelDelegate {
func didChange(repositories: [Repository]) {
self.repositories = repositories
DispatchQueue.main.async {
self.view?.updateTableView()
}
}

func didReceive(error: Error) {
func didReceive(error: SessionTaskError) {
DispatchQueue.main.async {
//TODO: change error message
self.view?.showAlert(with: error.localizedDescription)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,20 @@ class SearchViewPresenterSpy: SearchView {
}

class SearchModelStub: SearchModelProtocol {

var repositories: [Repository] = []

var delegate: SearchModelDelegate?
var sessionTask: SessionTask?

private var fetchRepositoriesResponse: [String: Result<[Repository], Error>] = [:]
private var fetchInitialRepositoriesResponse: [String: Result<[Repository], Error>] = [:]

func addFetchRepositoriesResponse(text: String, result: Result<[Repository], Error>) {
self.fetchRepositoriesResponse[text] = result
func addFetchInitialRepositoriesResponse(query: String, result: Result<[Repository], Error>) {
self.fetchInitialRepositoriesResponse[query] = result
}

func fetchItems(text: String) {
guard let response = fetchRepositoriesResponse[text] else {
fatalError("fetchItemsResponse not found when text is \(text)")
func fetchInitialRepositories(query: String) {
guard let response = fetchInitialRepositoriesResponse[query] else {
fatalError("fetchItemsResponse not found when text is \(query)")
}

switch response {
Expand All @@ -54,6 +54,7 @@ class SearchModelStub: SearchModelProtocol {
}

func cancel() {}
func fetchAdditionalRepositories() {}
}

extension SearchModelStub {
Expand All @@ -75,7 +76,7 @@ class SearchViewPresenterTest: XCTestCase {

let response: Result<[Repository], Error> = .success([.init(id: 0, name: "swift", fullName: "swift/apple", language: "c", stargazersCount: 12345, watchersCount: 12345, forksCount: 12345, openIssuesCount: 12345, description: "oss", owner: .init(avatarUrl: "https:/hogehoge", login: "apple"))])

stub.addFetchRepositoriesResponse(text: textToSearch, result: response)
stub.addFetchInitialRepositoriesResponse(query: textToSearch, result: response)
let exp = XCTestExpectation(description: "searchButtonTappedが呼ばれた後に実行されるupdateTableViewを待つ")
spy._updateTableView = {
exp.fulfill()
Expand All @@ -95,7 +96,7 @@ class SearchViewPresenterTest: XCTestCase {
let textToSearch = "swift"
let response: Result<[Repository], Error> = .failure(SearchModelStub.APIErrpr.unknownError)

stub.addFetchRepositoriesResponse(text: textToSearch, result: response)
stub.addFetchInitialRepositoriesResponse(query: textToSearch, result: response)
let exp = XCTestExpectation(description: "searchButtonTappedが呼ばれた後に実行されるshowAlertを待つ")
spy._showAlert = { text in
exp.fulfill()
Expand Down