Android TabLayoutとViewPagerでスワイプ連動タブ制御

よくあるこういったUIのやつ

f:id:letitride:20190621173600p:plain:h350

コンテンツ部分を横スワイプでタブが左右に切り替わる

使用するコンポーネント

ViewPager

スワイプに応じてページングされたFragmentを生成し、自動でアタッチする。

TabLayout

TabのUI表現及び、リスナーを管理する。また、生成したViewPagerをセットすることでViewPagerの挙動に応じてタブを切り替えることが可能。

導入

app/buile.gradle
implementation 'com.android.support:design:28.0.0'

implementation 'com.google.android.material:material:1.0.0'こちらを入れてしまうとAndroidXのコンポーネントと競合しビルドが通らなかった。

Manifest merger failed : Attribute application@appComponentFactory value=(android.support.v4.app.CoreComponentFactory) from [com.android.support:support-compat:28.0.0] AndroidManifest.xml:22:18-91
TabLayoutとViewPagerの配置

TabLayoutとViewPagerを配置するのみでよい。また、TabItemは後述するViewPagerで生成されるので、ここで記載の必要はない

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".Posts.MainActivity">
    
    <android.support.design.widget.TabLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" android:id="@+id/tabLayout">
    </android.support.design.widget.TabLayout>

    <android.support.v4.view.ViewPager
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="0dp" app:layout_constraintTop_toBottomOf="@+id/tabLayout"
            app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

</android.support.constraint.ConstraintLayout>
Fragmentの定義

Fragmentはタブ、スワイプで切り替わるコンテンツ部分の定義をすればよい。R.layout.fragment_personal_postsにレイアウトを記述しているものとする。

class Tab1PostsFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_personal_posts, container, false)
    }
    companion object {
        @JvmStatic
        fun newInstance() =
            PersonalPostsFragment().apply {
                arguments = Bundle().apply {
                }
            }
    }
}

Tab1PostsFragmentと同様にTab2PostsFragmentも定義しておく。

Tab生成、制御の実装

Activityまたは上記2つのFragmentの親となるFragmentに記述する。

Tabの生成、制御はViewPagerのAdapterが管理する

viewPager.adapter = object : FragmentPagerAdapter(supportFragmentManager){
    override fun getCount(): Int { ... }
    override fun getItem(position: Int): Fragment { ... }
    override fun getPageTitle(position: Int): CharSequence? { ... }
}

FragmentPagerAdapterは一度作成されたFragmentはViewPagerが保持し、FragmentStatePagerdAapter、切り替わりの度にSaveInstance、破棄、生成される。

実装必須となるメソッドは2つだが、タブのタイトルを返すメソッドも実装できる。

getCount()はViewPagerのページ数を返す

val fragmentList = listOf<Fragment>(
    Tab1PostsFragment.newInstance(),
    Tab2PostsFragment.newInstance()
)
override fun getCount(): Int {
    return fragmentList.size
}

getItem()はpositionに応じたFragmentインスタンスを返す

override fun getItem(position: Int): Fragment {
    val fragment = fragmentList.get(position)
    return fragment
}

getPageTitle()はpositionに応じたタイトルを返す

override fun getPageTitle(position: Int): CharSequence? {
    val tabTitles = listOf<String>(
        "まとめブログ", "個人ブログ"
    )
    return resources.getString( tabTitles[position] )
}

最後にAdapterをセットしたViewPagerをTabLayoutに渡せばViewPagerに連動したタブの制御が可能となる。

viewPager.adapter = object : FragmentPagerAdapter(supportFragmentManager){ ... }
tabLayout.setupWithViewPager(viewPager)

タブのレイアウトにデザインを入れる場合

setupWithViewPager()後に

tabLayout.setupWithViewPager(viewPager)
Tab tab1 = tabLayout.getTabAt(0);
tab1.setText("Home");
tab1.setIcon(R.drawable.tab1);

val view = layoutInflater.inflate(R.layout.activity_main, null)
tab1.setCustomView(view);

のようにすれば、各タブの属性にアクセスできる。


Kotlin 戻り値をジェネリクスとした抽象メソッドの定義と実装、TemplateMethodの応用

APIで取得したJsonデータをパース、Modelメンバにパースした値をセットしてオブジェクトを返す。という処理をtemplate method的な固有実装のみサブクラスに実装したかったので勉強しました。

クラス関係

//ジェネリクスは継承先のクラスが指定する
open abstract class ApiModel<T> {
}

class DataModel:ApiModel<BlogModel>() {
    var last_name = ""  //jsonから取得した値が入ります
    var first_name = ""
}

抽象メソッドの定義と実装

各サブラクスにmodelオブジェクトを生成する処理を記述する

open abstract class ApiModel<T> {
    abstract fun createModel(jsonObject:JSONObject):T
}

class DataModel:ApiModel<DataModel>() {
    var last_name = ""  //jsonから取得した値が入ります
    var first_name = ""

    override fun createModel(jsonObject:JSONObject):DataModel{
        val model = DataModel()
        model.last_name = jsonObject.getString("last_name")
        model.first_name = jsonObject.getString("first_name")
        return model
    }
}

親クラスにtemplate methodの呼び出しメソッドを記述

Superクラスはサブクラスから渡されたジェネリクスに応じたオブジェクトを呼び出し元に返せばよい

open abstract class ApiModel<T> {
    abstract fun createModel(jsonObject:JSONObject):T
    //jsonが配列の場合
    fun stringToModels(apiResponseJson: String):List<T>{
        val returnModels = mutableListOf<T>()
        val jsons = JSONArray(apiResponseJson)
        (0..(jsons.length() - 1)).forEach { i ->
            //createModelはサブクラスで実装
            val model = createModel( jsons.getJSONObject(i) )
            returnModels.add(model)
        }
        return returnModels as List<T>
    }

    //jsonが単オブジェクトの場合
    fun stringToModel(apiResponseJson: String):T{
        val json = JSONObject(apiResponseJson)
        //createModelはサブクラスで実装
        val model = createModel( json )
        return model
    }
}

使用例

val dataModel:DataModel = DataModel().stringToModel("単オブジェクトのJson文字列")
val dataModels:List<DataModel> = DataModel().stringToModels("ルートが配列のJson文字列")

他のAPIを使用するクラスの場合、

class FooModel:ApiModel<FooModel>() {
    var foo_name = ""  

    override fun createModel(jsonObject:JSONObject):FooModel{
        val model = FooModel()
        model.foo_name = jsonObject.getString("foo_name")
        return model
    }
}

と、こんな感じでジェネリクスとJsonに関わる構造のみ記述すればよい。


Android 処理中インジゲーターを表示する

導入

ProgressDialogはdeprecatedになったので、ProgressBarを使用する

<ProgressBar
    style="?android:attr/progressBarStyle"
    android:id="@+id/progressBar"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="invisible" />

style="?android:attr/progressBarStyle"でくるくる回るインジゲーターや横棒の進捗バーを指定できる。

あとはコード側で処理開始時と終了時に表示制御すればよい。

//処理開始 インジゲーターを表示
progressBar.visibility = android.widget.ProgressBar.VISIBLE
doAction()
//処理終了 インジゲーターを非表示
progressBar.visibility = android.widget.ProgressBar.INVISIBLE

非同期処理中にインジゲーターを表示する

尚、上記例のdoAction()が非同期処理の場合、処理が終わる前にINVISIBLEとなるので、doAction()完了時のコールバックにINVISIBLEを渡す必要がある。

progressBar.visibility = android.widget.ProgressBar.VISIBLE
doAction{
    progressBar.visibility = android.widget.ProgressBar.INVISIBLE
}

また、当然ながらProgressBarはUIスレッド上で操作するので、progressBar.visibility = android.widget.ProgressBar.VISIBLEをワーカースレッドで設定するとクラッシュする。

WebViewのローディングに合わせてインジゲーターを表示する

WebViewにはローディングの開始と終了時のコールバックがあるので、そちらにインジゲータの処理を記述する。

webView.webViewClient = object: WebViewClient(){
    override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
        progressBar.visibility = android.widget.ProgressBar.VISIBLE
        super.onPageStarted(view, url, favicon)
    }
    override fun onPageFinished(view: WebView?, url: String?) {
        progressBar.visibility = android.widget.ProgressBar.INVISIBLE
        super.onPageFinished(view, url)
    }
}
webView.loadUrl( url )

またロード開始時はonPageStartedでキャッチするのだが、PDFファイル等WebViewがロードできないファイルに対してはキャッチできない。その場合はshouldOverrideUrlLoadingを使用する。shouldOverrideUrlLoadingは逆にPOSTからのレスポンス読み込みに対応したてないので必要に応じて切り替えを行おう。

参考 re-engines.com


Android ViewにPull to Refreshを導入する

導入

android.support.v4.widget.SwipeRefreshLayoutを使用する。

サポートライブラリを指定せず、<SwipeRefreshLayout></SwipeRefreshLayout>と記述すると、Error inflating class SwipeRefreshLayoutとなり、ビルドは通るがXMLパースでコケる。

<android.support.v4.widget.SwipeRefreshLayout
    android:id="@+id/swipe_refresh_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ListView
        android:id="+id/list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>

この記述だけで入れ子にしたViewを引っ張る動作と連動したインジゲーターが表示できる。

pullされた時の処理を記述する

onRefreshListenerを登録することで処理をセットできる

swipe_refresh_layout.setOnRefreshListener {
    doAction()
}

処理後インジゲーターを非表示にする

ただし、layoutに定義しただけだと、インジゲーターが表示され続ける。よって、コード側でインジゲーターを消す処理を記述する。

swipe_refresh_layout.setOnRefreshListener {
    doAction()
    swipe_refresh_layout.isRefreshing = false
}

注意点

SwipeRefreshLayoutはLayout内で記述された先頭のViewのみしか表示しない。よって以下のように記述した場合、下のTextViewが表示されない。

<android.support.v4.widget.SwipeRefreshLayout
    android:id="@+id/swipe_refresh_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ListView
        android:id="+id/list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <LTextView
        android:id="+id/text_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>


Android OSSライセンス画面をPluginで自動表示する

Androidアプリ開発自体、Apache License 2.0 のOSSを使用して開発を行う為、インクルージョン表記や帰属表記が必要。

こちらを使うと簡単に使用ライブラリがリスト化されたActivityを生成できます。 github.com

導入

Add the Gradle plugin

project/build.gradle

buildscript {
    repositories {
        google()
        jcenter()        
    }
    dependencies {
        // Add this line:
        classpath 'com.google.android.gms:oss-licenses-plugin:0.9.5'
    }
}

app/build.gradle

apply plugin: 'com.google.android.gms.oss-licenses-plugin'

dependencies {
    // ...
    // Add this line:
    implementation 'com.google.android.gms:play-services-oss-licenses:16.0.2'
}

build.gradle記述後、一度buildしておくとOssLicensesMenuActivityというActivityが自動で生成されます。

Activityへアクセスする

あとは、他のActivityと同様にstartActivityすればOSSの一覧が表示されます。

button.setOnClickListener{
    startActivity(Intent(this, OssLicensesMenuActivity::class.java))
}
f:id:letitride:20190619114342p:plain:h350f:id:letitride:20190619114352p:plain:h350


kotlinの無名関数、無名クラス、クロージャーとラムダ式

いつも、あれ?どうやって書くんだっけ?と都度ググっているので整理したことをメモ。

無名関数

基本

宣言と同時に関数オブジェクトに

val func1 = fun(data:Int):Int{ return data }
func1( 1 )
宣言済みメソッドを関数オブジェクトに
fun returnInt(data:Int){ return data }
// KFunction<Int, Int>
val func2 = ::returnInt
//大抵の場合はコールバックに渡すと思うので、型が合うようダウンキャストする
val func3 = ::returnInt as (data:Int)->Int
//または、
val func4:(data:Int)->Int = ::returnInt
引数なしメソッドを関数オブジェクトに

引数なしメソッドの場合はラムダ式で記述できる

val func5:()->Int = { 1 * 1 }

無名クラス

基本
val object1 = object {
    fun foo(){ }
}
object1.foo()
抽象クラスを継承した無名クラス
val object2 = object : BaseAdapter(){
    override fun getCount(): Int { return 0}
    override fun getItemId(position: Int): Long {return position.toLong()}
    override fun getItem(position: Int): Any {return position}
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {return convertView!!}
}
インターフェースを実装した無名クラス
val e = object: View.OnClickListener{
    override fun onClick(v: View?) { }
}

コールバック、クロージャとラムダ

コールバックの定義
val object1 = object {
    var value:Int = 0
    fun foo(){ }
    fun bar(callback:(v:Int)->Int){
        callback(value)
    }
}

与えられるコールバックをnullableにしたい場合は、(v:Int)->Intをnullableで括る

//引数callbackはOptionalになる
fun bar(callback:( (v:Int)->Int )?){
    //アンラップ
    callback?.let { callback -> 
        callback(value)
    }
}
コールバックの呼び出しとクロージャの利用
val i = 2
//その場でクロージャとなる無名関数を定義
object1.bar( fun(v:Int):Int{ return v * i } )
//定義済み関数オブジェクトを渡す(例:上記で定義したfunc3を渡す)
object1.bar( func3 )
//ラムダでクロージャを渡す
object1.bar( { v -> v * i } )
//ラムダの場合、barの引数が1つのみの場合、```()```を省略できる
object1.bar{ v -> v * i }
//barの最後の引数がコールバックの場合、```()```の後、ラムダを記述できる
object1.bar( 1, 2 ){ v -> v * i }
//コールバックの引数が2つ以上の場合はこう記述する
object1.bar{ k, v -> v * i + k }

ラムダ式中でのreturn

基本は最終行の式が評価がreturnされるが、guard return等でラムダ中でreturnしたいことがある。 普通にreturnすると呼び出し元メソッドのスコープをreturnしようとするのでエラーなる。

fun onResume(){
    object1.bar{ v ->
        //'return' is not allowed here
        if( v == 1){ return 1 }
        // valid return
        v * 2
    }
}

ラムダのローカルスコープに対してreturnするには以下のように記述すればよい

object1.bar{ v ->
    if( v == 1){ return@bar 1 }
    v * 2
}

インターフェース実装を受け取るメソッドにラムダの記述

実装メソッドが1つしか必要としないインターフェースを必要とするメソッドにはラムダ式で省略記述した実装を渡すことができる。

Button(this).setOnClickListener{ view ->
    //onClick時の実装を書ける
}

これは下記と等価の実装となる

Button(this).setOnClickListener( object: View.OnClickListener{
    override fun onClick(v: View?) {
    }
} )

ただし、インターフェースのみを無名クラスとしてoverride記述を省略することはできない

//これはダメ
val callback = object: View.OnClickListener{ view ->
}

これは多分、{}がラムダ文法ではなく、無名クラスobject:のクラス定義としての文法として意味をなしている為?


All com.android.support libraries must use the exact same version specification

Androidアプリにadmobの広告を入れようとして、build.gradleに

implementation 'com.android.support:appcompat-v7:27.+'
implementation 'com.google.android.gms:play-services-ads:17.2.0'

と記述したら、ライブラリの中に27.1.1と26.1.0が混在していて、customtabsのバージョンが合ってないと怒られる...

All com.android.support libraries must use the exact same version specification (mixing versions can lead to runtime crashes). Found versions 27.1.1, 26.1.0. Examples include com.android.support:animated-vector-drawable:27.1.1 and com.android.support:customtabs:26.1.0 less... (⌘F1) 
Inspection info:There are some combinations of libraries, or tools and libraries, that are incompatible, or can lead to bugs. One such incompatibility is compiling with a version of the Android support libraries that is not the latest version (or in particular, a version lower than your targetSdkVersion).  Issue id: GradleCompatible

customtabsのバージョンを個別に記述して解決する

implementation 'com.android.support:appcompat-v7:27.+'
implementation 'com.google.android.gms:play-services-ads:17.2.0'
implementation 'com.android.support:customtabs:27.1.1'