RecyclerView で Section を表示する
アクトインディ Advent Calendar 2016 2日目の記事になります。2015年の11月にアクトインディに入社して 1 年が経ちました。 入社してからは iOS アプリの開発に携わらせてもらい、現在は鋭意 Android アプリの開発をしています。
子どもとのお出かけ先に困っている方は、是非いこーよアプリを使ってみてください ^^
アドベントカレンダーの本題ですが、今回は Android の RecyclerView で Section をどうやって表現するかの話を書きたいと思います。アプリの設定画面など、表示する項目が決まっていて、セクションで区切られたリストを表示したいケースがあると思います。iOS では TableView に Section が用意されているので容易に実装できますが Android では List にセクションといった要素がない為、セクション用のレイアウトとリストアイテム用のレイアウトファイルを項目で切り替えて実装する必要が出てきます。今回は Enum を使った実装を紹介したいと思います。実装のサンプルは Kotlin 1.0.5 になります。
やっていることは簡単で ヘッダーには setOnClickListener を設定せず、リストアイテムの場合に setOnClickListener を設定するといった事をしています。
1. リストで表示する Enum クラスを作成する
enum class ListItem(val title: String) { HeaderFruits(title = "果物"), Apple(title = "りんご"), Orange(title = "オレンジ"), HeaderVegetables(title = "野菜"), Carrot(title = "人参"), Onion(title = "玉ねぎ"), HeaderDrinks(title = "飲み物"), Milk(title = "牛乳"), Water(title = "水") }
2. Adapter を作成する
Adapter の雛形は以下になります。コメントアウトの箇所をそれぞれ実装していきたいと思います。
class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { private val items = ListItem.values() override fun getItemViewType(position: Int): Int { // 1. ヘッダーかリストアイテムかで異なる viewType を返すように実装する } override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder { // 2. viewType によって読み込む layout ファイルを指定する } override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) { // 3. text のセットとアイテムクリック時のイベントをセットする } override fun getItemCount(): Int { return this.items.count() } }
1. ヘッダーかリストアイテムかで異なる viewType を返すように実装する
ヘッダー用かリストアイテム用のレイアウトを返すか判別する為の viewType を返却するように設定します。
class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { private val items = ListItem.values() private val headerType = 0 private val itemType = 1 override fun getItemViewType(position: Int): Int { val item = this.items[position] when (item) { ListItem.HeaderFruits, ListItem.HeaderVegetables, ListItem.HeaderDrinks -> return headerType else -> return itemType } }
2. viewType によって読み込む layout ファイルを指定する
設定した viewTyep を利用してヘッダー用とリストアイテム用の Header を指定した ViewHolder を返却するようにします。
class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ... 省略 ... override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(context) when (viewType) { headerType -> return ViewHolderItem(inflater.inflate(ViewHolderItem.headerLayoutId, parent, false)) itemType -> return ViewHolderItem(inflater.inflate(ViewHolderItem.itemLayoutId, parent, false)) else -> return ViewHolderItem(inflater.inflate(ViewHolderItem.itemLayoutId, parent, false)) } } ... 省略 ... class ViewHolderItem(view: View) : RecyclerView.ViewHolder(view) { companion object { val headerLayoutId = R.layout.header val itemLayoutId = R.layout.item } }
3. text のセットとアイテムクリック時のイベントをセットする
あとは Enum で定義した各アイテム毎に行いたいアクションを定義します。
class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ... 省略 ... override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) { holder ?: return val item = this.items[position] val textView = holder.itemView.findViewById(R.id.item_text_view) as TextView textView.text = item.title holder.itemView.setOnClickListener { this.action(item = item) } } ... 省略 ... private fun action(item: ListItem) { when (item) { ListItem.Apple -> { Toast.makeText(context, "りんご", Toast.LENGTH_LONG).show() } ListItem.Orange -> { Toast.makeText(context, "オレンジ", Toast.LENGTH_SHORT).show() } ListItem.Carrot -> { Toast.makeText(context, "人参", Toast.LENGTH_SHORT).show() } // ... else -> {} } }
adapter をセットする
最後に activity で adapter をセットします。
class MainActivity : AppCompatActivity() { private val recyclerView: RecyclerView by bindView(R.id.recycler_view) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) this.recyclerView.setHasFixedSize(true) this.recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) val settingAdapter = ItemsAdapter(context = this) recyclerView.adapter = settingAdapter } }
Enum で定義した順番がリストに表示される順番という気持ち悪さはありますが、ある程度はアイテムが増減しても、そこまで改修が大きくなることはない実装かと思います。各レイアウトファイルは以下のような感じです。
activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/activity_main" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="test.sectionrecyclerview.MainActivity"> <android.support.v7.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent"/> </RelativeLayout>
item.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/item_text_view" android:padding="16dp" android:layout_width="match_parent" android:layout_height="wrap_content"/> </FrameLayout>
header.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:background="@android:color/darker_gray" android:id="@+id/item_text_view" android:padding="16dp" android:layout_width="match_parent" android:layout_height="wrap_content"/> </FrameLayout>
Swift2 - storyboard で 高さ可変の ScrollView を autolayout で設定する
多くの場面で、高さが可変の ScrollView を作成する事があると思います。むしろ Web と連携するアプリなんかはそのようなケースの方が多いんじゃないでしょうか。極力ストーリーボードの autolayout を使って、高さ可変の ScrollView を設定する方法を紹介したいと思います。
- View に ScrollView を配置する
- ScrollView の横スクロールを制御する ContentView を ScrollView 配下に追加する
- ScrollView の横幅を設定する為の Equal Width を設定
- UILable などの高さの要素を持つパーツを追加して ScrollView を動的に制御する
1. View に ScrollView を配置する
View に ScrollView を配置して ScrollView の autolayout 設定は 上下左右全て 0 を指定します。
2. ScrollView の横スクロールを制御する ContentView を ScrollView 配下に追加する
ScrollView 配下に View を追加し、ContentView と名前を付けます。 ContentView の autolayout 設定は ScrollView に対して上下左右全て 0 を指定します。
この状態では、まだ autolayout の制約エラーで赤い状態になっています。 ScrollView は横にも縦にもスクロールできる状態の View なので、中の要素が空の状態では横幅と高さが決まらないからです。そこで、横幅を決める為の設定と高さを決める為の設定を入れてあげます。
3. ScrollView の横幅を設定する為の Equal Width を設定
ScrollView と ContentView を選択し Equal width の設定を入れます。
4. UILable などの高さの要素を持つパーツを追加して ScrollView を動的に制御する
親の View の高さは、高さ要素を持つ UILabel や UIButton などを子 View で持つことで、親 View に高さを指定しなくても、子 View の高さによって動的に変化してくれるのが autolayout の一番の利点だと思います。ここでは説明をシンプルにする為に UILable を一つ配置することで ScrollView の高さを動的に変更する方法を紹介したいと思います。
UILabel を ContentView 配下に追加し、ContentView に対して 左: 10 上: 50 右: 10 下: 10 の設定をします。
これでようやく Storyboard の autolayout の制約エラーが解消されました。この状態でシミュレーターを起動すると全くスクロールしない状態かと思います。これは、高さがスクリーンサイズを超えていないからスクロール領域がなく、スクロールしない状態になっています。
試しに UILable の bottom の autolayout の設定を 1000 とかにしてみて、シミュレーターを起動します。
指定した余白分スクロールできることが確認できるかと思います。今回の例では UILable 一つでしたが、複数の要素を ScrollView 上に配置したとしても、同じ原理で ScrollView の高さを動的に保つことができるようになります。
Swift2 - Storyboard で指定した AttributedText のフォントサイズが反映されない
テキストの行間を広げたくて storyboard で UILabel の Text のタイプを Plain から Attributed に変えて font size を 12, line spacing を 5 で設定してあげたところ storyboard ではきちんとフォントサイズと行間が効いていたのですがシミュレーターで確認したら、行間は反映されているんだけどフォントサイズが 17pt と変な挙動をしてました。
問題は iPhone だけに対応するアプリだったので storyboard の画面設定を w:Compact h: Regular で作成していて w:Any, h:Any の設定が残っていた事でした。 w:Any h:Any の設定をきちんと削除する事でシミュレーターにも正しく反映されました。
問題発生と解決の手順
w:Compact h: Regular で Attributed に切り替え、行間を指定する
シミュレーターを起動するとフォントサイズが正しく反映されていない
w:Any h:Any に storyboard の画面を切り替え UILable の Text のタイプを Plain に切り替える
w:C h:R のところに System 17.0 の設定が残っているので削除する
w:Compact h: Regular に戻り、再度 Attributed に切り替え、行間を指定する
シミュレーターを起動すると正しくフォントサイズが反映されている
Swift2 - クラスの継承とプロトコルの適合(採用)の両方の条件を満たす条件をジェネリクスで書く
Swift2.2, Xcode7
modal で呼び出す画面は、どの ViewController からも呼び出される可能性がある為、ページ遷移用の Utility に定義する様にしています。その時に困るのが、 UIViewController を継承して、かつ、モーダルを閉じる delegate ( protocol )を採用しているケースです。例として以下のようなケースを考えたいと思います。
- modal を呼び出す ParentViewController 。 delegate で modal を閉じる。
- modal で呼び出される側は ChildViewController
storyboard は ViewController と 1 対 1 で分割している
ParentViewController
protocol ChildViewControllerDelegate: class { func modalDismiss() } class ParentViewController: UIViewController, ChildViewControllerDelegate { ... func modalChild() { // TransitionUtility は ChildViewController を modal で呼び出す処理 TransitionUtility.modalChild(self) } func modalDismiss() { self.dismissViewControllerAnimated(true, completion: nil) }
- ModalViewController
class ModalViewController: UIViewController { weak var delegate: ChildViewControllerDelegate? ... func close() { self.delegate.modalDismiss() } }
このような状況で modal を表示する処理を書こうと思うと UIViewController の継承と EditEmailViewControllerDelegate の Protocol の採用でちょっと困ったことになります。
struct TransitionUtility { static func modal(viewController: UIViewController) { let storyboard = UIStoryboard(name: "Child", bundle: nil) guard let modalViewController = storyboard.instantiateViewControllerWithIdentifier("Child") as? ChildViewController else { return } modalViewController.delegate = viewController // ↑ viewController は ChildViewControllerDelegate を採用していないので delegate に指定できないよと怒られる。 viewController.presentViewController(modalViewController, animated: true, completion: nil) } }
struct TransitionUtility { static func modal(viewController: EditEmailViewControllerDelegate) { let storyboard = UIStoryboard(name: "Child", bundle: nil) guard let modalViewController = storyboard.instantiateViewControllerWithIdentifier("Child") as? ChildViewController else { return } modalViewController.delegate = viewController viewController.presentViewController(modalViewController, animated: true, completion: nil) // ↑ viewController は EditEmailViewControllerDelegate なので UIViewController の持つ presentViewController は利用できない。 } }
このような時に利用できるのがジェネリクスです。ジェネリクスを利用することで UIViewController を継承しており、かつ、ChildViewControllerDelegate を採用しているクラスを引数に定義することができます。実装方法は以下です。
static func modalChild<T where T: UIViewController, T: ChildViewControllerDelegate>(viewController: T) { let storyboard = UIStoryboard(name: "Child", bundle: nil) guard let modalViewController = storyboard.instantiateViewControllerWithIdentifier("Child") as? ChildViewController else { return } modalViewController.delegate = viewController viewController.presentViewController(modalViewController, animated: true, completion: nil) }
これで ChildViewController の modal の呼び出しは TransitionUtility.modalChild(self)
といった形で呼び出せるようになります。
Swift2 - Quick + Mockingjay で Alamofire の非同期通信のテストをする
API との通信をテストする時、実際にリクエストを投げてしまうとサーバーへの負荷やテスト時間など、コストが多くかかってしまうので、Alamofire のリクエストを Quick と Mockingjay を使ってスタブする方法を紹介したいと思います。
- Quick: RSpec 風に書ける単体テストライブラリ Quick
- Mockingjay: URL リクエストをモックできるライブラリ Mockingjay
以前書いた Swift2 Alamofire + ObjectMapper で API クライアントを作成する のリクエストに対してテストを書いてみたいと思います。テストするリクエストは以下です。
API.call(Endpoint.User.Find(id: 1)) { response in switch response { case .Success(let result): print("success \(result)") case .Failure(let error): print("failure \(error)") } }
import Alamofire import ObjectMapper class Endpoint { enum User: RequestProtocol { typealias ResponseType = UserEntity case Find(id: Int) var method: Alamofire.Method { switch self { case .Find: return .GET } } var path: String { switch self { case .Find(let id): return "/users/\(id)" } } } }
import ObjectMapper class UserEntity: Mappable { var id: Int? var name: String? var email: String? var url: String? required convenience init?(_ map: Map) { self.init() } func mapping(map: Map) { id <- map["id"] name <- map["name"] email <- map["email"] url <- map["url"] } }
- テストを書く
- スタブ用の json ファイルを用意する
- json ファイルから NSDate に変換する処理は繰り返し利用する為 Helper クラスを用意する
1. テストを書く
テストの完成形は以下の様になります。
- Quick には toEventually という非同期の処理様のテストメソッドがあるのでそれを利用します
- Class のプロパティとしてテスト対象のデータを宣言しないと上手くtoEventually メソッドが使えません
self.stub のメソッドが Mockingjay によるスタブです。 jsonData で指定した data がレスポンスとして返ります
UserSpec
import Quick import Nimble import Mockingjay import ObjectMapper @testable import projectName class UserSpec: QuickSpec { var user: UserEntity? var stubUser: UserEntity? var error: NSError? override func spec() { describe("Endpoint.User.Find") { context("正常系") { beforeEach { let data = SpecHelper.readJSONFile("parent") self.stub(http(.GET, uri: "/users/1"), builder: jsonData(data)) let JSONString = String(data: data, encoding: NSUTF8StringEncoding) self.stubUser = Mapper<UserEntity>().map(JSONString) } afterEach { self.user = nil self.stubUser = nil self.removeAllStubs() } it("指定したユーザー情報が取得できること") { API.call(Endpoint.User.Find(id: 1)) { response in switch(response) { case .Success(let result): self.user = result case .Failure(let error): print(error) } } expect(self.user!.id).toEventually(equal(self.stubUser!.id)) expect(self.user!.name).toEventually(equal(self.stubUser!.name)) expect(self.user!.email).toEventually(equal(self.stubUs!.email)) expect(self.user!.url).toEventually(equal(self.stubUs!.url)) } } context("異常形") { context("APIがメンテナンス中") { beforeEach { self.stub(http(.GET, uri: "/users/1"), builder: http(503)) } afterEach { self.removeAllStubs() } it("503 エラーが返却されること") { API.call(Endpoint.PutFavorite(id: facilityId)) { response in switch response { case .Success(_): break case .Failure(let error): self.error = error } } expect(self.error?.userInfo.description).toEventually(equal("[NSLocalizedFailureReason: Response status code was unacceptable: 503]")) expect(self.error?.code).toEventually(equal(-6003)) } } } } } }
2. スタブ用の json ファイルを用意する
let data = SpecHelper.readJSONFile("parent") self.stub(http(.GET, uri: "/users/1"), builder: jsonData(data))
Mockingjay では NSData を jsonData に渡してあげることで uri に指定したリクエストをスタブし data をレスポンスとして返してくれます。 json データは記述が多くなることが多いのでレスポンス用の json ファイルを作成します。
- user.json
{ "id": 1, "name": "ティナ・ブランフォード", "email": "tina@example.com", "url": "https://ja.wikipedia.org/wiki/ファイナルファンタジーVI" }
3. json ファイルから NSDate に変換する処理は繰り返し利用する為 Helper クラスを用意する
用意しておくと便利です。
- SpecHelper.swift
static func readJSONFile(name: String) -> NSData { let path: String? = NSBundle(forClass: self).pathForResource(name, ofType: "json") let fileHandle: NSFileHandle? = NSFileHandle(forReadingAtPath: path!) let data: NSData! = fileHandle?.readDataToEndOfFile() return data }