はじめに
本記事はGlobis Advent Calendar15日目の記事です。
業務で動画学習アプリを作っている中で、YouTubeの画面回転の仕様を真似しようとしたら一筋縄ではいかなかったので備忘録として残しておきます
長いので要約するとrequestedOrientationとOrientationEventListenerを使って実現しましたという話です。
YouTube再生画面の画面回転仕様について
普段YouTubeで動画を視聴する中で特に違和感なく利用していたのですが、詳細仕様を意識して触っていなかったため、この機会にじっくり観察してみました。
試した環境はAndroid 11のPixel 3a XLで、YouTubeアプリのバージョンは16.46.37です。
画面表示の向き
言うまでもありませんが、YouTubeの再生画面には縦向きと横向きの表示があります。
縦向き
まずは基本の縦向きです。
このときプレイヤーの右下には全画面表示するボタンが表示され、押下すると横向きの全画面表示に切り替わります。
横向き(全画面表示)
プレイヤー右下の全画面表示ボタンを押下すると、画面表示が時計回りに90°回転し全画面表示になります。
このときプレイヤーの右下には全画面表示を終了するボタンが表示され、押下すると縦向きの表示に切り替わります。
厳密に言うと横向きは上記だけでは不十分で、更にそこから180°回転させたパターンが存在します。
上図では右側に表示されていた端末のボタン群が、下図だと左側に表示されています。
画面表示の向きのまとめ
縦向きの0°を始点として、時計回りに90°、または反時計周りに90°回した横向きが2パターンの合計3パターンの画面表示の向きがあります。
また、余談ですが縦向きを逆さまにした180°では画面は回転しませんでした。
端末を傾けたときの挙動
端末を傾けたときの挙動は、端末側で設定できる自動回転ON/OFFの設定値によって挙動が異なります。
自動回転OFFのとき
- 縦向きのとき
- 端末を90°左に傾けても、縦向きが継続する
- 端末を90°右に傾けても、縦向きが継続する
- 横向きのとき
- 端末を0°の位置に戻しても、横向きが継続する
- 最初の横向きから180°傾けると、回転し反対の横向きに切り替わる
自動回転ONのとき
- 縦向きのとき
- 端末を90°左に傾けると、横向きに切り替わる
- 端末を90°右に傾けると、横向きに切り替わる
- 横向きのとき
- 端末を0°の位置に戻すと、縦向きに切り替わる
端末を傾けたときの挙動のまとめ
- 自動回転OFF
- 縦向きのときは端末を左右に90°傾けても画面表示は切り替わらない
- 横向きのときは端末を傾けるとその向きに適した横向きに切り替わる
- 自動回転ON
- 表示の向きに関わらず端末を傾けるとそれに沿った向きの表示に切り替わる
という、ここまでは至極真っ当な挙動かと思います。
全画面表示/解除ボタンが絡んだときの挙動
YouTubeはプレイヤーの右下に全画面表示を切り替えるボタンがあるのですが、これが絡むと少々話がややこしくなってきます。
なぜなら、全画面表示ボタンを押下するタイミングによっては、実際の端末の傾きと表示されている画面表示の向きが異なる場合があるからです。
また、更に厄介なことに、端末側の自動回転ON/OFFの設定値によっても挙動が異なってきます。
自動回転OFFのとき
- 縦向きのとき
- 全画面表示ボタンを押下すると、横向きに切り替わる
- 表示されている横向きから見て180°回転させると、反対側の横向きに切り替わる
- なお、端末を縦向きに戻してもいずれかの横向きが継続する
- 表示されている横向きから見て180°回転させると、反対側の横向きに切り替わる
- 全画面表示ボタンを押下すると、横向きに切り替わる
- 横向きのとき
- 全画面解除ボタンを押下すると、縦向きに切り替わる
- 上記から端末をいずれかの横向きに戻しても縦向きが継続する
- 全画面解除ボタンを押下すると、縦向きに切り替わる
自動回転ONのとき
- 縦向きのとき
- 全画面表示ボタンを押下すると、横向きに切り替わる
- 一度端末を横向きにするまでは、そのままの向きが継続する
- 一度端末を横向きにすると、それ以降は端末の傾きに応じた画面回転をする
- 一度端末を横向きにするまでは、そのままの向きが継続する
- 全画面表示ボタンを押下すると、横向きに切り替わる
- 横向きのとき
- 全画面解除ボタンを押下すると、縦向きに切り替わる
- 一度端末を縦向きにするまでは、そのまま縦向きが継続する
- 一度端末を縦向きにすると、それ以降は端末の傾きに応じた画面回転をする
- 一度端末を縦向きにするまでは、そのまま縦向きが継続する
- 全画面解除ボタンを押下すると、縦向きに切り替わる
全画面表示/解除ボタンが絡んだときの挙動のまとめ
- 端末の自動回転がOFFのとき
- 全画面表示に切り替えた際に、端末の傾きに応じた横向きになり、全画面表示を解除すると縦向きに固定される。
- 端末の自動回転がONのとき
- 全画面表示/解除ボタンを押下すると表示の向きが切り替わるが、一度端末の傾きを表示されている向きと揃えると、それ以降は端末の傾きに応じた画面回転をする
仕様のまとめ
整理すると下記の4つの概念が絡んで画面回転の挙動が決定されます。
- 画面表示の向き(縦 or 横)
- 端末の傾き(0~359°)
- 全画面表示/解除ボタン押下
- 自動回転の設定値(ON or OFF)
パターンを書き出すと少々膨れてしまいますが、下記のような挙動になります。
自動回転OFFのとき
- 縦向きのとき
- 端末の傾きを360°どの角度に変更しても縦向きが継続する
- 全画面表示ボタンを押下すると横向きになる
- 横向きのとき
- 端末の傾きを左右いずれかに90°変更しても横向きが継続する
- 端末の傾きを180°変更すると、画面が回転して反対側の横向きになる
- 全画面解除ボタンを押下すると縦向きになる
自動回転がONのとき
- 縦向きのとき
- 端末を傾けると、その傾きに応じた画面回転をする(逆さまの縦向きを除く)
- 全画面表示ボタンを押下すると横向きになる
- 端末の傾きを横向きに揃えるまでは横向き表示が継続し、その後は端末の傾きに応じた画面回転をする
- 横向きのとき
- 端末を傾けると、その傾きに応じた画面回転をする(逆さまの縦向きを除く)
- 全画面解除ボタンを押下すると縦向きになる
- 端末の傾きを縦向きに揃えるまでは縦向き表示が継続し、その後は端末の傾きに応じた画面回転をする
実装方法
YouTubeの再生画面の画面回転仕様を把握したところで、同様の動作をするように実装していきます。
サンプルとして、縦向きと横向きに応じてボタンの文字と動作が変わるアプリを考えます。
縦向きのときはEXPANDというボタンが表示され、押下すると横画面表示に変わります。
横向きのときはCONTRACTというボタンが表示され、押下すると縦画面表示に変わります。
(アイコンを用意するのが面倒だったので文字のボタンでお茶を濁しています)
全画面表示/解除ボタンの挙動を再現する
画面右下にあるボタンを再現し、押下されるごとに縦向き/横向きを切り替えられるようにしていきます。
レイアウトの用意
このサンプルではDataBindingを使用します。
また、ボタンは別個に用意しておきisPortrait
の値に応じて表示/非表示を切り替えています。
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="isPortrait"
type="Boolean" />
<import type="android.view.View" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button_expand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="expand"
android:visibility="@{isPortrait ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/button_contract"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="contract"
android:visibility="@{isPortrait ? View.GONE : View.VISIBLE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
ManifestへのconfigChanges属性の追加
構成の変更を自分で処理するに従い、画面の向きが変更された時にActivity#onConfigurationChanged
が呼ばれるよう、MainActivityの属性にandroid:onConfigChanges=orientation|screenSize
を追加します。
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize"
android:exported="true">
// 中略
</activity>
MainActivityの実装
onCreate
での初回読み込み時とonConfigurationChanged
が呼び出されるタイミングでxml側のisPortrait
を更新します。
画面の向きはAndroidManifest.xmlでActivityのandroid:screenOrientation
に値を設定することで指定できるのですが、指定してしまうと画面の向きの設定が固定されてしまいます。
今回はボタン押下時に動的に画面を回転させたいため、そのような場合にはActivity#setRequestedOrientation
を呼び出します。
注意点としてEXPANDボタンが押された時にリクエストするのはActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
ではなくActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
です
両者は一見似ているのですが前者は90°で固定されてしまうのに対し、後者はその名にSENSORが入っているようにセンサーの状態に準ずる横向きの表示になります。
「自動回転OFFの状態で全画面表示に切り替えた際に、端末の傾きに応じた横向きになる」という挙動を再現するためには後者が適切です
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(
this,
R.layout.activity_main
)
binding.buttonExpand.setOnClickListener {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
binding.buttonContract.setOnClickListener {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
updateScreenOrientation(resources.configuration)
}
private fun updateScreenOrientation(config: Configuration) {
binding.isPortrait = config.orientation == Configuration.ORIENTATION_PORTRAIT
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateScreenOrientation(newConfig)
}
ここまでの実装における問題点
これで自動回転がOFFのときの全画面表示/解除ボタン押下時の動作仕様は満たせるようになりました。
- 自動回転OFFのとき
- 縦向きのとき
- 全画面表示ボタンを押下すると横向きになる
- 横向きのとき
- 端末の傾きを180°変更すると、画面が回転して反対側の横向きになる
- 全画面解除ボタンを押下すると縦向きになる
- 縦向きのとき
- 自動回転がONのとき
- 縦向きのとき
- 全画面表示ボタンを押下すると横向きになる
- 端末の傾きを横向きに揃えるまでは横向き表示が継続し、その後は端末の傾きに応じた画面回転をする
- 全画面表示ボタンを押下すると横向きになる
- 横向きのとき
- 全画面解除ボタンを押下すると縦向きになる
- 端末の傾きを縦向きに揃えるまでは縦向き表示が継続し、その後は端末の傾きに応じた画面回転をする
- 全画面解除ボタンを押下すると縦向きになる
- 縦向きのとき
しかし、ビルドして動作を確認してみるとこのままでは自動回転ONの時の仕様を満たせません。
具体的には一度ボタンを押してしまうと、端末の傾きによる画面回転がうまく動作しません。
その理由は、ボタン押下時に指定されるrequestedOrientation
がSCREEN_ORIENTATION_PORTRAIT
かSCREEN_ORIENTATION_SENSOR_LANDSCAPE
のどちらかだからです。
この値が指定されてしまった後だと、端末の傾きに応じた画面回転をしません。
画面の自動回転ONのときの挙動を再現する
requestedOrientationの指定だけでは完全な再現ができませんでした、ではどうすればよいのか。
最初に考えたのは自動回転がONの時、画面回転をした後に端末センサーによって表示の向きが決まるSCREEN_ORIENTATION_SENSOR
を指定することでした。
そこでonConfigurationChanged
で指定することを試みたのですが、実行してみると回転した画面がすぐに元に戻ってしまいました
この原因は画面表示の向きと、端末の傾きは必ずしも一致しないことによるものです。
例えば、端末の傾きが0°且つ縦向きの時に全画面表示ボタンを押すと、画面表示の向きは横向きになりますが、端末の傾きはまだ0°のままです。
端末の傾きが0°の状態でSCREEN_ORIENTATION_SENSOR
を指定すると、直ちに「端末は縦向きである」と判断されてしまうため、すぐに縦向きに戻されてしまいます。
ふりだしに戻り、別の手段での解決策を探していったところ、OrientationEventListenerによって求めている挙動を実現できそうということが分かりました
OrientationEventListenerとは?
APIレベル3から存在している端末の傾きが変わった時にSensorManagerからの通知を受け取るためのヘルパークラスです。
端末の傾きが変わるたびにonOrientationChanged
が呼び出され、0~359までの度数が分かります。
余談ですが私自身はこれまで使う場面がなかったため、今回調べるまでその存在を知りませんでした
OrientationEventListenerを用いた実装
今回はまず、抽象クラスであるOrientationEventListenerを実装したOrientationHelperクラスを作成し、それをMainActivityから利用していきます。
OrientationHelper.kt
自動回転OFFのときに端末の傾きが必要になるのは全画面表示だけであり、なおかつSCREEN_ORIENTATION_SENSOR_LANDSCAPE
が指定されていればこちらでハンドリングの必要はありません。
そのため、自動回転ON/OFFの設定値を取得し、もし設定がOFFだったら早期リターンをしています。
また、端末の傾きが変わるたびに逐一onOrientationChanged
が呼ばれ通知回数が多くなりがちなので、このコードでは流量を減らすために500msの制限を設けています。
class OrientationHelper(
private val context: Context
) : OrientationEventListener(context) {
interface OnOrientationChangeListener {
fun onOrientationChanged(orientation: Int)
}
private var onOrientationChangeListener: OnOrientationChangeListener? = null
private var lastTime: Long = 0
override fun onOrientationChanged(orientation: Int) {
if(!isAutoRotateEnabled()) return
val currentTime = System.currentTimeMillis()
if (currentTime - lastTime < 500) return
onOrientationChangeListener?.onOrientationChanged(orientation)
lastTime = currentTime
}
fun setListener(onOrientationChangeListener: OnOrientationChangeListener?) {
this.onOrientationChangeListener = onOrientationChangeListener
}
private fun isAutoRotateEnabled(): Boolean =
Settings.System.getInt(
context.contentResolver,
Settings.System.ACCELEROMETER_ROTATION
) == 1
}
MainActivity.kt
先述の画面回転ボタンに関連するコードは省略してあります。
onCreateでOrientationHelperのインスタンスを作成し、onResume/onPauseで有効/無効の切り替えをします。
端末の傾きが通知されるのがonOrientationChangedメソッドで、この中の処理が今回の肝です。
現在設定されているrequestedOrientation
の値、最後の表示の向き、現在の端末の傾きを比較し、適切なタイミングでrequestedOrientation
に値を設定します。
また、今回の実装ではSCREEN_ORIENTATION_SENSOR
を指定してセンサーに頼ることはなく、端末の傾きに応じた3種類いずれかの設定値を動的に指定していることに注意してください
設定値 | 効果 |
---|---|
SCREEN_ORIENTATION_PORTRAIT | 縦固定(0°) |
SCREEN_ORIENTATION_REVERSE_LANDSCAPE | 横固定(90°) |
SCREEN_ORIENTATION_LANDSCAPE | 横固定(270°) |
class MainActivity : AppCompatActivity(), OrientationHelper.OnOrientationChangeListener {
private lateinit var orientationHelper: OrientationHelper
private var currentOrientation: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
// 中略
orientationHelper = OrientationHelper(this)
}
override fun onResume() {
super.onResume()
orientationHelper.apply {
setListener(this@MainActivity)
enable()
}
}
override fun onPause() {
super.onPause()
orientationHelper.apply {
setListener(null)
orientationHelper.disable()
}
}
override fun onOrientationChanged(orientation: Int) {
val lastOrientation = currentOrientation
if (orientation == OrientationEventListener.ORIENTATION_UNKNOWN) {
currentOrientation = -1
return
}
if (orientation >= 350 || orientation <= 10) {
if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE && lastOrientation == 0 ||
requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE && lastOrientation == 0 ||
requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE && lastOrientation == 0
) return
if (currentOrientation == 0) return
currentOrientation = 0
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else if (orientation in 80..100) {
if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT && lastOrientation == 90) return
if (currentOrientation == 90) return
currentOrientation = 90
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
} else if (orientation in 260..280) {
if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT && lastOrientation == 270) return
if (currentOrientation == 270) return
currentOrientation = 270
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
}
}
これで実装は終わりです。
requestedOrientationに加えOrientationEventListenerを利用することによって、先程だった自動回転がONのときの動作仕様がになりました。
- 自動回転がONのとき
- 縦向きのとき
- 全画面表示ボタンを押下すると横向きになる
- 端末の傾きを横向きに揃えるまでは横向き表示が継続し、その後は端末の傾きに応じた画面回転をする ⇛
- 全画面表示ボタンを押下すると横向きになる
- 横向きのとき
- 全画面解除ボタンを押下すると縦向きになる
- 端末の傾きを縦向きに揃えるまでは縦向き表示が継続し、その後は端末の傾きに応じた画面回転をする ⇛
- 全画面解除ボタンを押下すると縦向きになる
- 縦向きのとき
完成品
出来上がったサンプルアプリの動作仕様とGIFアニメをまとめておきます。
自動回転OFFのとき
- 縦向きのとき
- 端末の傾きを360°どの角度に変更しても縦向きが継続する
- 全画面表示ボタンを押下すると横向きになる
- 横向きのとき
- 端末の傾きを左右いずれかに90°変更しても横向きが継続する
- 端末の傾きを180°変更すると、画面が回転して反対側の横向きになる
- 全画面解除ボタンを押下すると縦向きになる
自動回転がONのとき
- 縦向きのとき
- 端末を傾けると、その傾きに応じた画面回転をする(逆さまの縦向きを除く)
- 全画面表示ボタンを押下すると横向きになる
- 端末の傾きを横向きに揃えるまでは横向き表示が継続し、その後は端末の傾きに応じた画面回転をする
- 横向きのとき
- 端末を傾けると、その傾きに応じた画面回転をする(逆さまの縦向きを除く)
- 全画面解除ボタンを押下すると縦向きになる
- 端末の傾きを縦向きに揃えるまでは縦向き表示が継続し、その後は端末の傾きに応じた画面回転をする
おわりに
本家YouTubeの内部実装は全く異なるものかもしれませんが、再生画面の回転仕様はrequestedOrientationとOrientationEventListenerを使うことで再現することができました。
もし再現したくなったときはこの記事のことを思い出すか、もっと良い方法を見つけたらこっそり教えて下さい。
参考
Androidプログラマへの道 ~ Moonlight 明日香 ~ 画面の向きを設定する
チラシの裏的備忘録 Androidで設定を参照, 操作するあれこれ -画面の設定編-
Change Screen Orientation programmatically using a Button