くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

Kotlinのオブジェクト式について

個人的な意見ですが、オブジェクト式や無名オブジェクトを理解するのに若干苦労したのでまとめようと思います

合わせて、無名関数も苦労したので合わせて記載しようかと思います

オブジェクト式とは?

オブジェクト式では無名オブジェクト(無名クラス)の定義・生成を同時に行います

また、無名オブジェクトはクラスやオブジェクト自体に名前が無いのが特徴です

オブジェクト式を使って生成したオブジェクトは、変数に格納するか、関数に渡すパラメータとして使用します(そうしないと参照できないため)

オブジェクト式の定義方法

objectの後に括弧({})を使い、その中にプロパティやメソッドを定義します

例えば次のような使い方です

val test = object {
    val number = 1
    fun run() = "テスト"
}
println(test.number)  //1
println(test.run())   //テスト

インターフェイスを継承する

オブジェクト式はクラスや1つ以上のインターフェイスを継承できますアプリ開発でよく使う例は、インターフェイスを実装した無名クラスです

interface TestInterface {
    fun showMessage(): String
}
val x = object: TestInterface {
   //インターフェイスのメソッドをオーバーライド
   override fun showMessage(): String {
       return "メッセージ表示"
    }
}

println(x.showMessage())   //メッセージ表示

さらに無名クラスは関数のパラメータ(引数)として、関数に渡すことができます

先程使用したインターフェースを引き続き使用します。 まず関数定義は次です

//インターフェイスを実装したオブジェクトを引数に取る
fun runFunction(o: TestInterface) {
    println(o.showMessage())
}

そして関数を実行するのは次のようになります

runFunction(object: TestInterface {
    override fun showMessage(): String {
        return "スタート"
    }
})

無名関数

無名関数は無名オブジェクトと同じように宣言と同時に関数オブジェクトになるものです。また、関数自体に名前がないです

fun(a:Int, b:Int):Int = a+b

無名関数は値として使用するため変数にセットできます

val addItem = fun(a:Int, b:Int):Int = a+b
println(addItem(3, 6))

ちなみに、ラムダ式も無名関数の一種です

val addLambda = {a:Int, b:Int -> a+b}
println(addLambda(3, 6))

無名関数は高階関数の一種です。最後に高階関数について軽く説明します

高階関数

Kotlinにおいて高階関数は第一級オブジェクトとして扱えます。第1級オブジェクトは関数リテラルとして表現できます

関数リテラルとは、関数を 直接、波括弧 「{ }」 内に記述する記法です。関数リテラル記法で記述すると、コードが即座にそのまま式として渡せます

つまり、関数を引数で受け取ったり、関数を返したりする関数のことを高階関数と呼びます

例えば次のような処理です

// body は () -> T の関数型で、引数なしで T 型の戻り値を返す関数を表している。
fun <T> lock(lock: Lock, body: () -> T): T {
  lock.lock()  // 処理を実行する前にロックする
  try {
    return body()  // 渡された関数を実行
  }
  finally {
    lock.unlock()
  }
}

上記の関数を呼び出す場合には引数に別の関数を渡します

// toBeSynchronized()関数を宣言
fun toSynchronize() = test()

// このtoBeSynchronized()関数を渡したいのだが、それには::で参照を取得する
val result = lock(lock, ::toSynchronize)

もしくは次のようにラムダを渡すこともあります(こちらの方が一般的)

val result = lock(lock, { test() })

// 最後の引数が関数なら、引数を並べる( )括弧の外に出す
lock (lock) {
  test()
}

引数として関数を渡すと、変数名の後に: ()->Stringのように、関数の型を明示します

例えば、Intを引数として受け取り、Stringを返す関数は、(Int)->Stringのように表現します。 ->の左が因子、 ->の右側が戻り値を意味します

そして、引数は括弧で囲む必要があります

もし引数が2つ以上ある場合、 (Int, Int)->Stringように表現することができます。 引数がない場合は、 ()->Stringのように括弧の中を空にします

またここまでの説明をもとにしてよく使うsetOnClickListenerを見ていきます

setOnClickListenerは Viewクラスのメソッドであり、それを継承する任意のクラスで使用できます。 本来であればonClickメソッドをoverrideした状態までをコード化するのですが、次のように省略することもできます

// View.OnClickListenerの単一抽象メソッドはonClickメソッドで、これらはViewを引数にとり戻りなしで共通
button.setOnClickListener(Object: View.OnClickListener {
    override fun onClick(view View?){
        TapAction()
    }
})

↓

// これを高階関数で省略すると
button.setOnClickListener{ view -> TapAction() }

↓

// ラムダのviewパラメータは使用しないので更に省略する
button.setOnClickListener{ TapAction() }

参照

dogwood008.github.io

qiita.com

RecyclerViewのGridLayoutについて

GridLayoutはリストを格子状に並べるという形式です

以前RecyclerViewについての記事を投稿した際にLayoutManagerにGridLayoutManagerというパターンがあるというのを記載しています

kumaskun.hatenablog.com

今回はGridLayoutManagerについて少し深掘りしようと思います

全てのセルが同じViewHolderで構成されているパターン

まずは全てのセルが同じViewHolderで構成されるパターンです

基本的にLinerLayoutManagerと作り方は同じでAdapterを作成してRecyclerViewと紐付けをします

ただ、GridLayoutManagerは紐付け方が異なるのでそこにフォーカスを当てて説明します

Adapterやセル(アイテム)のレイアウトは他のリスト表示の時と変わらないので下記まとめています

class HomeAdapter(private val countList: List<Int>): RecyclerView.Adapter<HomeAdapter.CountListRecyclerViewHolder>() {
    private lateinit var listener: HomeAdapterListener
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeAdapter.CountListRecyclerViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val view = inflater.inflate(R.layout.grid_list, parent, false)
        return CountListRecyclerViewHolder(view)
    }

    override fun getItemCount(): Int {
        return countList.size
    }

    override fun onBindViewHolder(holder: CountListRecyclerViewHolder, position: Int) {
        holder.countText.text = countList[position].toString()

        holder.itemView.setOnClickListener {
            listener.contentTapped(position)
        }
    }

    class CountListRecyclerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var countText: TextView = itemView.findViewById(R.id.gridTextView)
    }

    fun setListener(listener: HomeAdapterListener) {
        this.listener = listener
    }

    interface HomeAdapterListener {
        fun contentTapped(position: Int)
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="6dp"
    app:cardElevation="6dp"
    app:contentPadding="8dp"
    app:cardUseCompatPadding="true">
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <View
            android:id="@+id/image_view"
            android:layout_width="match_parent"
            android:layout_height="160dp"/>

        <TextView
            android:id="@+id/gridTextView"
            android:textSize="20sp"
            android:textColor="@android:color/black"
            android:textAlignment="center"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </LinearLayout>
</androidx.cardview.widget.CardView>

異なるのはLayoutManagerです。格子状に並べるためにはGridLayoutManagerを使用します

val countList = listOf(1, 2, 3, 4, 5, 6, 7, 8)
val adapter = HomeAdapter(countList)
homeRecyclerView.adapter = adapter
homeRecyclerView.layoutManager = GridLayoutManager(
    requireContext(),
    2,
    RecyclerView.VERTICAL,
    false
)

adapter.setListener(object: HomeAdapter.HomeAdapterListener {
    override fun contentTapped(position: Int) {
        // セルをタップした時の処理
    }
})

今回のGridLayoutManagerで横に2つずつ並べるようなリスト表示の設定を行っています(GridLayoutManagerの初期化の引数の2つ目でspanCountを指定します)

ビルドしてみると次のような表示になります

セルのマージンを調整する

GridLayoutは基本的にはセル間のマージンがありません。マージンを追加する場合には例えばセルの右側に10dpマージンを追加するなどの方法が考えられます

しかし、その方法だと全てのセルに同じマージンが追加されることになります。特定のセルのみマージンを変更したい場合には別の方法で対応する必要があります

今回はItemDecorationを使う方法で対応します

ItemDecorationクラスはアイテムの装飾を行います。例えば、区切り線や今回のようにマージンを追加したりできます

ItemDecorationを継承したカスタムクラスを作成して、RecyclerViewに追加します

class CustomGridItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
    private val margin = 120

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val params = view.layoutParams as? GridLayoutManager.LayoutParams
        val manager = parent.layoutManager as? GridLayoutManager
        val spanIndex = params?.spanIndex ?: 0
        val spanSize = params?.spanSize ?: 1
        val spanCount = manager?.spanCount ?: 1

        // セルのindex
        val itemPosition = parent.getChildAdapterPosition(view)

        // セルのindexが2番目以下
        val isTop = itemPosition < 2

        val isStart = spanIndex == 0
        val isEnd = spanIndex + spanSize == spanCount

        // 今回はviewHolderに応じて処理を行っているが、1つのviewHolderで1つのクラスを作成しても問題ない
        when (parent.findContainingViewHolder(view)) {
            is HomeAdapter.CountListRecyclerViewHolder -> {
                // outRect.set(leftのマージン, topのマージン, rightのマージン, bottomのマージン)
                outRect.set(0, if (isTop) margin else 0, 0, 0)
            }
        }
    }
}

今回は0番目と1番目の上部に120のマージンを表示させるようにしました(getItemOffsetsはアイテムの上下左右に空間を設ける処理を行います)

ItemDecorationを継承したカスタムクラスを作成したら次のようにRecyclerViewに追加します

homeRecyclerView.addItemDecoration(CustomGridItemDecoration(requireContext()))

これで次のように特定のセルにマージンを追加できます

1列に表示させる数を変更する

先程までは1列に2つ表示させるという規則的なリストを作成しました

ただ、1列目は1つ、2列目は2つというように不規則に表示させたい場合もあると思います

ここでは不規則に表示させるパターンについて説明しようと思います

1列に表示させる数を設定するためにはGridLayoutManagerクラスのSpanSizeLookupを使います。 SpanSizeLookupのgetSpanSizeで列に何個表示させるかを指定します

val layoutManager = GridLayoutManager(requireContext(), 2).apply {
    spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int {
   // 2番目のセルは2つ分のサイズを使用して、それ以外は1つ分(半分)のサイズを使用する
            if (position == 2) {
                return 2
            }
            return 1
        }
    }
}

homeRecyclerView.layoutManager = layoutManager

今回の場合にはGridLayoutManager(requireContext(), 2)で1列に2つ表示するように設定した上で2番目は2つ表示させるサイズを1つのセルに使い、 それ以外は1個分のサイズつまり画面の半分のサイズでセルを表示させるようにspanSizeLookupを設定しています

ビルドすると次のようになります

こうすることで不規則な個数を表示させることができますが、if (position == 2)のようにpositionを固定できない場合にはどうしますか?むしろ、固定できない場合が多いと思います

そのような場合にはViewTypeで区別することができます

まずAdapterでは他のAdapterとは重複しないViewTypeを指定します。 Int型を指定しなければいけないのでレイアウトIDを指定することが多い気がしますが、重複しなければいいです

override fun getItemViewType(position: Int): Int {
    return R.layout.grid_list
}

そしてadapterのViewTypeを元にしてアイテムのサイズを指定します。 この場合には条件分岐をposition固定にしなくていい反面、Adapterを複数作る必要があります

val layoutManager = GridLayoutManager(requireContext(), 2).apply {
    spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int {
            return when (adapter.getItemViewType(position)) {
                R.layout.grid_list -> 1 
            }
        }
    }
}

ItemDecorationの補足

ここで少しItemDecorationについて補足します。ItemDecorationの大まかな概要は次のようになります

class CustomGridItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
    // アイテムの上下左右へ空白(透明)を設ける
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
    }

    // アイテムが描画される前に描画されるため、RecyclerViewの下に装飾を表示
    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDraw(c, parent, state)
    }

    // アイテムが描画された後に描画されるため、RecyclerViewの上に装飾を表示
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
    }
}

onDrawOveronDrawは次のイメージです

少し前のItemDecorationの解説でで区切り線を追加したりするという説明をしたと思います

区切り線を入れたい場合は、DividerItemDecorationという既成のItemDecorationがAPIに用意されています

val deco = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
homeRecyclerView.addItemDecoration(deco)

区切り線をカスタマイズしたい場合やRecyclerViewをカスタマイズしたい場合には、getItemOffsetでサイズを指定してonDrawもしくはonDrawOver(あるいは両方)で描画処理を行うというということを 意識して実装しましょう!

参照

qiita.com

star-zero.medium.com

star-zero.medium.com

AndroidでWebViewを使ってみる

今回はAndroidのWebViewについてです

iOSと違う部分もあるので見ていきましょう!

Webページを表示させる

Webページを表示させるだけであれば簡単にできます

前提としてAndroidManifest.xmlでインターネットの権限の設定を行ってください

<manifest ... >
    <uses-permission android:name="android.permission.INTERNET" />
</manifest>

レイアウトファイルでwebviewを作成して、表示させるURLを読み込む処理を行うのみです

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <WebView
        android:id="@+id/webView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
webView.loadUrl("http://www.example.com")

表示させるのはこれだけでできます。ここからは細かい設定について説明します

WebViewの設定を追加する

WebViewの設定はWebSettingsを利用します。設定は次のように行います(一部抜粋します)

// JavaScriptを有効にする(ここでのwebViewはレイアウトのID)
webView.settings.javaScriptEnabled = true

// ピンチ操作によるズームイン・ズームアウトの有効化
webView.settings.builtInZoomControls = true

// ダブルタップによるズームイン・ズームアウトの有効化
webView.settings.useWideViewPort = true

// ズームボタン(右下に表示される±のルーペ)の表示・非表示切り替え
webView.settings.displayZoomControls = true

// // Storageの使用許可の設定
webView.settings.domStorageEnabled = true

// trueの場合にはHTMLのwidthがWebViewより大きい時に全体を表示するようによしなに調整される
webView.settings.loadWithOverviewMode = true

// ズーム機能をサポートするか否か
webView.settings.setSupportZoom(true)

webViewClientの設定

webViewClientはWebView内のセキュリティやルーティングなどのほとんどのアクションを担当するオブジェクトです

webViewClientの主な処理は次です

webView.webViewClient = object :WebViewClient() {

      // WebView内の別のリンクを表示させる際に
      override fun shouldOverrideUrlLoading(
          view: WebView?,
          request: WebResourceRequest?
      ): Boolean {
          // shouldOverrideUrlLoadingから別のActivityやアプリを起動する場合はtrueを返す。falseを返すとWebView内にロードした結果を表示
          return false
      }

      // ページ読み込み開始時
      override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
          super.onPageStarted(view, url, favicon)
      }

      // ページ読み込み完了時
      override fun onPageFinished(view: WebView?, url: String?) {
          super.onPageFinished(view, url)
      }

      // アクセスエラーなどのWebリソースの読み込みエラー発生時の処理
      override fun onReceivedError(
          view: WebView?,
          request: WebResourceRequest?,
          error: WebResourceError?
      ) {
          super.onReceivedError(view, request, error)      
      }

      // SSLエラー時
      override fun onReceivedSslError(
          view: WebView?,
          handler: SslErrorHandler?,
          error: SslError?
      ) {
          super.onReceivedSslError(view, handler, error)

          handler?.cancel()
    }
}

注意点としてはonReceivedErrorを受け取ってもonPageFinishedは実行されるので、画面の制御などに気をつけましょう

HTMLを表示させる

次にHTMLを表示させる方法です

HTMLはassetsフォルダを作成しておきます

assetsフォルダ

HTMLファイルを置いたら、呼び出してロードするだけで表示できます

// 今回の場合にはtest.htmlというファイルを格納している想定です
webView.loadUrl("file:///android_asset/test.html")

CSSを適用させるにはページ読み込み完了したらCSSファイルを読み込む処理を行います

CSSはHTMLと同様にassetsフォルダに置いているという条件付きです。またonPageFinishedで行います

private fun injectCSS() {
            try {
                val inputStream = assets.open("style.css")   // CSSファイル名を指定
                val buffer = ByteArray(inputStream.available())
                inputStream.read(buffer)
                inputStream.close()
                val encoded = Base64.encodeToString(buffer , Base64.NO_WRAP)
                webframe.loadUrl(
                    "javascript:(function() {" +
                            "var parent = document.getElementsByTagName('head').item(0);" +
                            "var style = document.createElement('style');" +
                            "style.type = 'text/css';" +
                            // Tell the browser to BASE64-decode the string into your script !!!
                            "style.innerHTML = window.atob('" + encoded + "');" +
                            "parent.appendChild(style)" +
                            "})()"
                )
            } catch (e: Exception) {
                e.printStackTrace()
            }

        }

Javascriptとの連携

Javascriptからアプリの処理を実行したり、アプリからJavaScriptのメソッドを実行したい場合があると思います

Javascriptとの連携はWebChromeClientを使います

WebChromeClientはブラウザー周辺のchromeの要素を変更するイベントに反応するためのイベントインターフェイスです。 JavaScript、favicons、進行状況と現在のページのタイトルの更新などが含まれます

Javascriptからアプリの処理を実行する

例えば、Javascriptでボタンのタップアクションをアプリ側で受け取りアプリで処理を行う場合です

HTMLは次の例を使用します

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <script type="text/javascript">
            function showMessage() {
                alert("showMwssage");
            }
            function setMessage(message) {
                document.getElementById("text").innerHTML = message;
            }
        </script>
        <input type="button" value="Button"
               onClick="showMessage();" />
        <p id="text"></p>
    </body>
</html>

HTMLで設定されるfunction showMessage()でalertを設定して次のようにonJsAlertでメッセージを取得できます

webView.webChromeClient = object :WebChromeClient() {
    override fun onJsAlert(
       view: WebView?,
        url: String?,
        message: String?,
        result: JsResult?
    ): Boolean {
        if (!message.isNullOrEmpty()) {
            Log.d("jsAlertMessage", message)   // showMwssageが取得される
         }
        return true
    }
}

アラートメッセージを取得したら処理が行われるようにしたらJavascriptからアプリの処理を実行したことになります

アプリからJavaScriptのメソッドを実行する

今度は先程と逆でアプリからJavascriptを実行する方法です

次のようにevaluateJavascriptJavascriptの関数を呼び出します

val message = "TEST"
webView.evaluateJavascript("setMessage(`${message}`);")

注意点

オフラインなどでWebViewが表示できない時はデフォルトのエラー画面ではなく、エラーメッセージを表示させる・エラーイラストを表示させるなどの対応を行うのが望ましいです

そうしないとリリース時にNGを受けることがあります

iOSではNGを受けないかもしれないですが、同じ対応を行うのが望ましいです

参照

android-tech.jp

developer.android.com

qiita.com

qiita.com

android.camposha.info

qiita.com

qiita.com

ExpandableListViewを使ってみる

Androidで開閉式のアコーディオンテーブルを作成する際にExpandableListViewを使います

あまり使う機会がなく記事もあまりなさそうなので、記事にしようと思います

ExpandableListViewを使う手順

ExpandableListViewをアプリで使う手順は次の手順で行います。ExpandableListViewはListViewを継承しているため基本的にはListViewと同じ手順を踏むことになります

  1. ExpandableListViewの配置
  2. リストグループビューとリストアイテムビューのレイアウトの作成
  3. リストビューの設定(Adapterなど)

ExpandableListViewの配置

まずはExpandableListViewを配置します(Activity)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ExpandableListView
        android:id="@+id/expandableList"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

リストグループビューとリストアイテムビューのレイアウトの作成

次にリストグループビューとリストアイテムビューのレイアウトを作成します。 リストグループビューが親要素でリストアイテムビューは子要素に相当します

今回は親要素と子要素を同じレイアウトにします。 今回はexpandable_cell.xmlという名前で作成します

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/expandableTitleTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="14dp"
        android:layout_marginEnd="10dp"
        android:layout_marginBottom="15dp"
        tools:text="TextView"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

リストビューの設定

まずはAdapterを作成します。AdapterはBaseExpandableListAdapterクラスを継承して作成します

BaseExpandableListAdapterを継承すると次のようにoverrideしないといけない関数を次のように設定します

class ExpandableListAdapter(
    private val context: Context,
    var dataList: Map<String, List<String>>?
 ): BaseExpandableListAdapter() {
    //親要素の数を返す
    override fun getGroupCount(): Int {
        if (!dataList.isNullOrEmpty()) {
            return dataList!!.keys.size
        }
        return 0
    }

    //子要素の数を返す
    override fun getChildrenCount(p0: Int): Int {
        if (!dataList.isNullOrEmpty()) {
            val key = dataList!!.keys.elementAt(p0)
            val list = dataList!![key]
            if (!list.isNullOrEmpty()) {
                return list.size
            }
        }
        return 0
    }

    //親要素を取得
    override fun getGroup(p0: Int): Any {
        if (!dataList.isNullOrEmpty()) {
            return dataList!!.keys.elementAt(p0)
        }
        return ""
    }

    //子要素を取得
    override fun getChild(p0: Int, p1: Int): Any {
        if (!dataList.isNullOrEmpty()) {
            val key = dataList!!.keys.elementAt(p0)
            val list = dataList!![key]
            if (!list.isNullOrEmpty()) {
                return list[p1]
            }
        }
        return ""
    }

    //親要素の固有IDを返す
    override fun getGroupId(p0: Int): Long {
        return 0
    }

    //子要素の固有IDを返す(このサンプルでは固有IDは無いので0)
    override fun getChildId(p0: Int, p1: Int): Long {
        return 0
    }

    //固有IDを持つかどうかを返す(このサンプルでは持たないのでfalse)
    override fun hasStableIds(): Boolean {
        return false
    }

    //親要素のレイアウト生成
    override fun getGroupView(p0: Int, p1: Boolean, p2: View?, p3: ViewGroup?): View {
        var title = ""
        var convertView = p2
        if (!dataList.isNullOrEmpty()) {
            title = dataList!!.keys.elementAt(p0)
        }
        if (convertView == null) {
            val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
            convertView = layoutInflater.inflate(R.layout.expandable_cell, null)
        }

        val titleTextView = convertView!!.findViewById<TextView>(R.id.expandableTitleTextView)
        titleTextView.text = title

        return convertView
    }

    //子要素のレイアウト生成
    override fun getChildView(p0: Int, p1: Int, p2: Boolean, p3: View?, p4: ViewGroup?): View {
        var convertView = p3

        var title = ""
        val key = dataList!!.keys.elementAt(p0)
        val list = dataList!![key]
        if (!list.isNullOrEmpty()) {
            title = list[p1]
        }
        if (convertView == null) {
            val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
            convertView = layoutInflater.inflate(R.layout.expandable_cell, null)
        }

        val titleTextView = convertView!!.findViewById<TextView>(R.id.expandableTitleTextView)
        titleTextView.text = title

        return convertView
    }

    //子要素がタップ可能かどうかを返す(このサンプルでは可能にするのでtrue)
    override fun isChildSelectable(p0: Int, p1: Int): Boolean {
        return true
    }
}

dataListのkeyが親要素、valueが子要素になるようにしています

そして、adapterとExpandableListViewを紐づけます

val dictionary = mapOf("title1" to listOf("foo", "bar", "fizz", "buzz"), "title2" to listOf("fizz"))
adapter = ExpandableListAdapter(this, dictionary)
expandableList.setAdapter(adapter)

この状態で起動すると次のように開閉式メニューが実現できます

ただし、親要素の左側に開閉状態を表すマークが表示されています。 デフォルトで表示されるので表示した上で表示されるテキストの位置を変更するかマークを消すかどちらかで対応します

今回はマークを消す設定を追加します。 次のようにandroid:groupIndicator="@null"を追加することでマークを消すことができます

<ExpandableListView
        android:id="@+id/expandableList"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:groupIndicator="@null"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

あまり使うことのないレイアウトだと思いますが、ListViewを継承しているということを覚えておくと大まかに作成できると思います

参照

developer.android.com

www.fineblue206.net

stackoverflow.com

kamiya-kizuku.hatenablog.com

iOSにおける同期・非同期処理について

今回はモバイル開発で欠かせない同期・非同期処理の概念についてです

最初の方の概念の説明はAndroidも共通している内容ですが、後半はiOSに特化した内容になります

Androidの同期・非同期処理はこの前、技術書典で出版した本で軽く説明しているので気になる方は読んでみてください!!(今回はAndroidの解説は省きます)

同期・非同期とは?

まずは、同期・非同期処理とはいったい何なのかを説明しようと思います

同期

同期処理のイメージは次のようなものです

同期処理のイメージ

タスクA、タスクBを同期処理するアプリケーションがあったとします。このアプリケーションが、ユーザーAからリクエストを受けた場合を例にします

プログラムに書かれた通りの順番でタスクが処理されるので、タスクBが終わるまでタスクAの処理が中断され、ユーザーからは画面が固まったように見えてしまいます。

非同期

非同期処理とは、あるタスクを実行している最中に、その処理を止めることなく別のタスクを実行できる方式です

処理の流れは次のようなイメージです

非同期処理のイメージ

非同期処理の場合は、実行の順番を待たないため、処理が完了する順序はその都度異なります

非同期処理は、上図のように、ユーザーAのリクエストを処理中にユーザーBからのリクエストがあっても、ユーザーBはユーザーAの処理完了を待たずに、結果を受け取ることができます

非同期処理をうまく活用すると、全体の処理速度を速められるメリットがあります

このとき注意しておきたいのは、非同期処理と並行処理の意味です。並行処理とは文字どおり複数の処理を同時進行で行うことです

一方、非同期処理は処理を止めることなく実行できるというだけです

  • 並行処理:複数の処理を1つの主体が切り替えながらこなすこと
  • 並列処理:複数の処理を複数の主体で同時にこなすこと
  • 同期処理:複数の処理をこなす際、ある処理が別の処理の終了を待つような処理
  • 非同期処理:複数の処理をこなす際、ある処理は別の処理の終了を待たないような処理

iOSで非同期を行うためには?

では、iOSで非同期を行うためにはどうすればいいでしょうか?

iOSにおける並列(非同期)処理の手法には次の手法があります。最近だとasync/awaitが登場して一番使いやすいと思いますがここでは割愛します

  • Thread : スレッドを立ててスレッドの中で処理を行う。スレッドの管理やキューイングなどの管理はアプリケーションが行う
  • GCD(Grand Central Dispatch) : スレッドの管理などをOSレベルで実装したもの。処理をしたいタスクをClosureで渡す。渡されたタスクはQueue(キュー)に挿入されて逐次実行される

この中で、GCDがよく利用されています。Threadだとスレッドの処理などを自前で書かないといけないのでコードが冗長になってしまい大変です

そこで今回は、GCDについて解説を進めて行きます

GCDの仕組みを簡単に説明すると次のような仕組みです

  • DispatchQueueというキューに処理を行いたい内容(タスク)を追加する(GCDではQueueのことをDispatchQueueとも呼びます)
  • キューに追加されたタスクはFIFO(First In First Out)で順番に実行される
  • タスクの実行は別スレッド上で実行される
  • 処理の開始自体はQueueに追加された順に実行されます。

そして次のような特徴があります

  • タスクはClosureとして登録して、キューの管理はアプリケーション独自に作ったもの、アプリケーション起動時にシステム内で自動的に生成されるもの、の二種類がある
  • 適切なスレッドへのタスクの振り分けはOSが行う
  • アプリケーション内部で自動生成されたキューと、そのキューに追加されたタスクを実行するスレッドの管理はOSが自動的に行ってくれる

スレッドの生成や管理はシステムがやってくれるので重要なことは、実行したいタスクを定義して適切なDispatchQueueに追加することです 

次の処理で追加します

関数 補足
func async(group: DispatchGroup? = default, qos: DispatchQoS = default, flags: DispatchWorkItemFlags = default, execute work: @escaping () -> Void) Closureで定義された処理をqueueに追加する。タスクの処理は非同期で実行される
func sync(execute block: () -> Swift.Void) Closureで定義された処理をqueueに追加する。タスクの処理は同期的に実行される

asyncやsyncは次のようにして使います

DispatchQueue.global.sync {
}

DispatchQueue.main.async {
}

DispatchQueueにはキューが次のように存在します。

キューの種類 説明
serial dispatch queue(直列 ) タスクを同時に一つずつ追加された順に実行する仕組み。タスクは他のキューと独立したスレッド上で動作
concurrent dispatch queue(並列) 複数のタスクを同時に実行する。実行の順番はキューに追加した順番になるが、終了のタイミングの順序は保証されない。同時に実行するタスクの数はシステムの状況に応じて変化する。アプリケーションが所有するglobalQueueとアプリケーション内部で生成するqueueを持つことができる。キューには優先度をつけることができる
main dispatch queue アプリケーションのどこからでも利用することの出来るシリアルキュー(直列)で、アプリケーションのメインスレッド上で実行される。UIの更新などはこのキューを用いて行う必要がある

globalQueueは上記の表にもあるように実行優先度ごとにキューが管理されます

優先度 説明
優先度1:userInteractive ユーザーの入力に対してインタラクティブに実行。即時に実行されなければフリーズしているように見える処理に使う
優先度2:userInitiated ユーザーの入力を受けて実行される処理に使う
優先度3:default 優先度が指定されていない場合に指定される。明示的には指定しない
優先度4:utility プログレスバー付きのダウンロードなどに利用される。視覚的な情報の更新をするが、即時の結果を要求しない処理に使う
優先度5:background バックアップなどの見えないところで使われる。時間がかかっても問題ない処理に使う

例えば次のように使用します

let globalQueque = DispatchQueue.global(qos: .background)
globalQueque.async {
}

最後にThread.isMainThreadで、現在の処理がメインスレッドで実行されているのかを確認することができます。 スレッドのして方法に誤りがあってクラッシュすることもあるので、現在メインスレッドなのかを確認するために使います

そしてここまで説明した概念を元にConcurrencyを勉強しましょう!

参照

qiita.com

dev.classmethod.jp

developer.apple.com

developer.apple.com

developer.apple.com

qiita.com

FragmentManagerについて

今回はFragmentManagerについて調べたことをまとめようと思います

FragmentManagerをどのインタンスから作成するかを意識しないとうまく表示できなかったりと想定外なことが起こるので注意が必要です (今回は指定違いで想定外なことが起こっていました)

FragmentManagerとは?

FragmentManagerは、アプリのFragmentに対する追加、削除、置換、バックスタックへの追加などを実行するためのクラスです

FragmentManagerは生成されたFragmentのインスタンスの状況を管理して、再度呼ばれると復元してくれます

FragmentManagerがTransactionを使うことで、フラグメントに対する追加などのアクションを実行します

Transactionはいくつかの命令をまとめて処理するときに使われるものです。命令のうちどれかに不都合があると、処理自体が停止されます

主に使用するアクションは次のような実装です

// 追加
val testFragment = FirstFragment()
val fragmentTransaction = supportFragmentManager.beginTransaction()
fragmentTransaction.add(R.id.fragment_container, testFragment)
fragmentTransaction.commit()

// 置換
val testFragment = FirstFragment()
val fragmentTransaction = supportFragmentManager.beginTransaction()
fragmentTransaction.replace(R.id.fragment_container, testFragment)
fragmentTransaction.commit()

// 削除
val fragmentTransaction = supportFragmentManager.beginTransaction()
fragmentTransaction.remove(testFragment)
fragmentTransaction.commit()

// バックスタックへの追加
val fragmentTransaction = supportFragmentManager.beginTransaction()
fragmentTransaction.addToBackStack("")
fragmentTransaction.commit()

上記の例ではTransactionを定義する際にsupportFragmentManager.beginTransaction()を使いましたが、これはこのまま使用して想定外のことが起きないでしょうか?次の章で説明します!

FragmentManagerの種類(アクセス)

FragmentManagerには、使用するフラグメントの種類に対応したクラスがあります

公式ドキュメントに詳しく説明の記載がありますが、どこからアクセスされるかで使用するFragmentManagerが変わります。次のようにマッピング一覧で表されています

使用するFragmentManagerの一覧

また上記の「使用するFragmentManagerの一覧」図にあるLevelの定義は次のように具体例があります

すべての FragmentActivity とAppCompatActivityなどのサブクラスActivity内でアクセスする場合には、getSupportFragmentManager()メソッドを介して FragmentManager にアクセスします

Fragment内でアクセスする場合には2パターンあります

Fragment内でgetChildFragmentManager()を介して子Fragmentを管理するFragmentManagerへの参照を取得するパターンとそのホストであるFragmentManagerにアクセスする必要がある場合にはgetParentFragmentManager() を使用するパターンです

今回自分はFragment内で子Fragmentを管理するパターンでgetSupportFragmentManager()を使っていて表示がおかしくなっていました(BottomNavigationの1フラグメントの中でViewPagerを追加するという内容です)

一時的には表示がうまくいく可能性はありますが、リロードしたり、タブを切り替えて戻ってきたら表示されなくなるなど想定外のことがあり得ます。 FragmentManagerをどのように取得するかを意識して使うようにしましょう!

参照

developer.android.com

developer.android.com

SwiftのGenericsについて

今回はジェネリクスについてまとめます

ジェネリクス(Generics)は指定したタイプで柔軟に動作する再利用可能な関数や型を指定できる機能です

ジェネリクスを使用するには、関数名のあとに<T>を指定します

<T>は型引数(型パラメータ)と呼ばれるもので、この関数ではTという型を使用することを意味しています

<T>を指定すると、関数が呼び出されたときにSwiftのシステムで自動で適切な型に置き換えて処理を実行してくれます

ジェネリクスの型は<T>でなくても構いせんが、慣例として<T>が使われています

ジェネリクスは関数やクラスで使用することができます

Generics関数

func isEqualCheck<T: Equatable>(x: T, y: T) -> Bool {
      return x == y
}

上記ではxとyが等しいかをチェックしています。 EquatableをつけているのはTがEquatableプロトコルに準拠している型というのを表しています。 StringやFloat、BoolなどはEquatableプロトコルに準拠しているので使用できます。 逆に型によっては使用できない可能性がある場合(今回の場合に比較や+-などのオペレータの場合)にはTがどのプロトコルに準拠しているかを明示する必要があります

Genericsクラス

ジェネリクスクラスは次のようにして使います

class GenericsClass<T> {
    
    var item: T
    
    init(item: T) {
        self.item = item
    }
    
}
 
let generics1 = GenericsClass(item: 100)
 
let generics2 = GenericsClass(item: "TEST")

指定方法はGenerics関数と同じで型引数(<T>)を指定するだけです

Genericsプロトコル

Swiftではプロトコルにもジェネリクスを使用することができます

例えば、次のように使います

protocol TestProtocol {
    associatedtype T
    var item:T { get }
    func testValue(item:T)
}

// 1つ目の継承クラス
class Test1:TestProtocol {
    var item:Int = 100
    
    func testvalue(item: Int) {
        self.item += 100
    }
}

// 2つ目の継承クラス
class Test2:TestProtocol {
    var item:String = "100"
    
    func fruitvalue(item: String) {
        self.item = item + "円です"
    }
}

プロトコルジェネリクスを使用するためには、定義内でassociatedtype Tのように任意の型を指定する必要があります

型制約

Generics関数の説明で、TがEquatableプロトコルに準拠している型というのを表しました。 このように準拠するプロトコルスーパークラスなどの型引数に制限を追加することがあります。 これを型制約といいます

型制約を追加することでジェネリクス関数やジェネリクス型を細かくコントロールすることができます

今回はwhereを使った制限を紹介します!

func sort<T: Collection>(numbers: T) -> [T.Iterator.Element] where T.Iterator.Element: Comparable {
      return numbers.sorted()
}

上記の例の場合には型引数TはCollectionプロトコルに準拠している上にwhere T.Iterator.Element: Comparableで定義されているようにComparableプロトコルに準拠している T.Iterator.Elementに制限されます

ちなみに、Elementは複数要素のそれぞれの型を表しています

このように細かい制限を加えることによって想定外の型が入る余地を省いています。ジェネリクスはどんな方も入る可能性があるため注意が必要です

参照

docs.swift.org

TableViewCellの再利用について

あるときこんなレビュー指摘がありました。

「CellのViewをhiddenにしているけど、ずっとhiddenになったままでいいの?再利用されるときに想定外の場合になる可能性があるよ」

最初、??と思ったのですが、そのときにTableViewCellは再利用されていることを知りました ※結構前の話です

今回はそんなセルの再利用についてまとめてみようと思います。

Cellの再利用

Cellを使う場面で真っ先に思いつくのはTableViewだと思います

TableViewのセルが大量にある場合にはViewの描画数が非常に多くなり、特にスクロール時には、パフォーマンスの低下が懸念されます

そのため、Cellの再利用で毎回新しくViewを作るのではなく以前に生成したcellを利用することで、メモリ割り当てを最小限にします

ただし、Cellの見た目を変更した際にリサイクル前のCellに対する変更が残ってしまうという問題がしばしば起こります。 例えばカスタムセルクラスの中で、データの中に画像が存在しない場合は、Cellに設置したimageViewを非表示にする処理を書いたとします。 そして、imageViewが非表示となったCellを再利用する時、画像の有無にかかわらずimageViewは非表示のままで表示されるので、注意が必要になります

Cell再利用の流れ

Cellを再利用するために、まず事前にregister(_:forCellReuseIdentifier:)を呼んで、ViewをCellのテンプレートとして登録します。 テンプレートはユニークなreuseIdentifierによって管理されます

次に、Cellの生成(参照)時、つまりfunc tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCellメソッドが呼ばれます。 このメソッドは、再利用可能なCellがあればそれを、なければ新しく作成したCellを返します。再利用可能なCellは、reuseIdentifierに紐づけられたreuse queueに格納されています

tableviewは裏側でreuse queueという、データの箱のようなものを持っており、reuseIdentifierごとにreuse queueが存在します。 画面外に出たCellは、自身のidentifierに紐づいたreuse queueに追加されます。 そして、同じidentifierのCellが表示されようとする時、queueから取り出されます。 次の図のようにスクロールしたら次のcellがqueueから取り出されるイメージです

Cell再利用で想定外の表示にならないようにするために

ここからはCell再利用で想定外の表示にならないためにやるべきデータ初期化のタイミングについて2つ紹介します。2つともやることは同じです

まずはfunc tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCellで行うパターンです

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell: UITableViewCell = tableView.dequeueReusableCell(withIdentifier: "cellidendifier",  for: indexPath)

      // 画像を初期化
      cell.imageView.image = nil
      cell.imageView.image = UIImage(named: "test")

      return cell
}

imageViewのimageを初期化した上で画像を設定しています。この方法だとfunc tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCellに多く初期化する処理を書く可能性があり、微妙なところです

もう1つはカスタムセルのprepareForReuse()で初期化を行うです

prepareForReuse()は再利用可能なセルを準備するときに呼ばれます。このタイミングで初期化を行います

class CustomTableViewCell: UITableViewCell {
    @IBOutlet private weak var label: UILabel!

    // セルのリサイクル対策での初期化処理を行う
    override func prepareForReuse() {
        super.prepareForReuse()
    }

    func configure(text: String) {
        label.text = text
    }
}

上記のようにするとconfigureメソッドやオートレイアウトを使う場合にはawakeFromNib()で初期化を行う必要がないです

参照

shiba1014.medium.com

developer.apple.com