Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

画面回転対応で対応すること

こんにちは、モバイルクライアントグループの隅山です。 最近フィットネスバイクを購入し、運動不足の解消を心がけています。

今年の6月より、AndroidのStudyplusアプリはタブレットでの動作を前提とした実装に切り替えました。 タブレットでも快適に利用できるようにするため、縦画面固定ではなく横画面にも対応しつつレイアウトや画面状態も考慮する必要がありました。 今回は縦画面と横画面を切り替える画面回転に関して対応した内容を簡単に紹介します。

はじめに

画面回転対応といっても多くの対応内容があるので、ここではデザイン以外の部分に絞って紹介します。

  • 紹介する内容

    • ActivityやFragmentのデータ保持
    • AlertDialogの表示方法
  • 紹介しない内容

    • 画面サイズに応じた適切なレイアウト

ActivityやFragmentのデータ保持

Androidでは画面回転すると画面が再構成され、状態が破棄されてしまいます。 そのため、ActivityやFragmentの中にデータを特に考慮せず保持しておくと画面回転の際にデータが失われてしまいます。

データ保持の方法としてSavedInstanceStateを利用すると再構成後にデータの復旧ができますが、弊社では ViewModel を用いてActivityやFragmentの再生成時にデータが破棄されないようにしています。 ViewModelはUI関連のデータを管理するためのクラスでありライフサイクルがActivityよりも長く、画面回転してもデータを保持してくれます。

SavedInstanceStateやViewModelの説明はtakahiromさんが詳しく紹介されているため、ここをご参考ください。

qiita.com

ViewModel導入サンプル

画面に必要なデータをViewModelに置くだけで画面回転時のデータ保持の問題は解決できます。 ViewModelの導入は非常に簡単です。

/** 導入前コード */
class SampleActivity : AppCompatActivity() {
    // 画面回転するたびに0に初期化されてしまう
    private var count: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        button.setOnClickListener {
            countUp()
        }
    }

    fun countUp() {
        count++
    }
}
/** 導入後コード */
class SampleViewModel : ViewModel() {
    private var count: Int = 0

    fun countUp() {
        count++
    }
}

class SampleActivity : AppCompatActivity() {
    private val viewModel: SampleViewModel by lazy {
        ViewModelProvider(this).get(SampleViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        button.setOnClickListener {
            viewModel.countUp()
        }
    }
}

LiveDataの導入および注意点

ViewModelを導入してUIとデータを切り離した際、UI-ViewModel間のデータやり取りで便利なのが LiveData となります。 LiveDataはデータ通信の結果に応じてUIを変更する場合、データのやり取りを非常に便利にするコンポーネントです。

データ通信の結果を反映させるためであればLiveDataをそのまま利用して問題ありませんが、ダイアログ表示や画面遷移などのイベントにLiveDataを利用する場合は注意点があります。 まず間違った導入サンプルがこちらです。

class SampleViewModel : ViewModel() {
    val isShowDialog = MutableLiveData<Boolean>(true)
}

class SampleActivity : AppCompatActivity() {
    private val viewModel: SampleViewModel by lazy {
        ViewModelProvider(this).get(SampleViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        viewModel.isShowDialog.observe(this) { isShow ->
            if (isShow) {
                showDialog()
            }
        }
    }

    fun showDialog() {
        // ダイアログの表示処理
    }
}

上のコードでも一見問題なく動作するのですが、画面回転を利用すると画面回転のたびにダイアログ表示される問題が発生します。 画面回転するとonCreateの処理が再び走るため、ViewModelのisShowDialogのtrueを再度参照して表示する流れでした。

この問題を解決するためにはいくつかの手法がありますが、2つ紹介します。

詳しい解説はそれぞれの参考資料に記載されているため省きます。 参考資料のEventラッパーのコードがこちらです。

/**
 * Used as a wrapper for data that is exposed via a LiveData that represents an event.
 */
open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set // Allow external read but not write

    /**
     * Returns the content and prevents its use again.
     */
    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    /**
     * Returns the content, even if it's already been handled.
     */
    fun peekContent(): T = content
}

そして、このEventラッパーを利用して問題解決したコードがこちらになります。

class SampleViewModel : ViewModel() {
    val isShowDialog = MutableLiveData<Event<Boolean>>(Event(true))
}

class SampleActivity : AppCompatActivity() {
    private val viewModel: SampleViewModel by lazy {
        ViewModelProvider(this).get(SampleViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        viewModel.isShowDialog.observe(this) {
            val isShow = it.getContentIfNotHandled() ?: return@observe
            if (isShow) {
                showDialog()
            }
        }
    }

    fun showDialog() {
        // ダイアログの表示処理
    }
}

このようにイベントでLiveDataを利用する場合はEventラッパーやChannelを利用することをおすすめします。

AlertDialogの表示方法

通信エラーで画面に必要なデータが取得できない場合、ダイアログで操作させずに前の画面に戻したいのに、画面回転でダイアログが消えてしまっては意味がありません。

弊社ではAlertDialogの表示は MaterialAlertDialogBuilder を利用していました。 しかし、MaterialAlertDialogBuilderで setCancelable) をfalseに設定してユーザー操作で消せないように防いでも、画面回転時にダイアログが消えてしまう問題が発生しました。

そのため、ダイアログが必須の箇所だけDialogFragmentを利用するようにしました。 画面に必要なデータ通信のエラーダイアログの例がこちらです。

class InitNetworkErrorDialog : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        isCancelable = false
        return MaterialAlertDialogBuilder(requireActivity())
            .setMessage(R.string.error_network)
            .setPositiveButton(android.R.string.ok) { _, _ ->
                activity?.finish()
            }
            .setCancelable(false)
            .create()
    }

    companion object {
        const val TAG = "NetworkErrorDialog"
        fun newInstance() = InitNetworkErrorDialog()
    }
}

おわりに

今回は画面回転対応の中の「ActivityやFragmentのデータ保持」と「AlertDialogの表示方法」を説明しました。 画面回転対応ではこれらに加えてデザイン周りの改修が必要となります。

画面回転を始めることが決まってから全てを始めると大変になってしまうので、これらのうちどこからでもいいので少しずつ導入してみてください。 より詳しい内容は DroidKaigi 2021 の「マルチデバイス対応で考慮すべきポイント」のセッションで発表予定です。