寝る前に使っていただくことを前提にした環境音のアプリに、「暗くする」というボタンを足した夜のことです。手元の Android 端末で何度も試して、暗くなること、ホームに戻せば元に戻ることを確かめ、その日は満足して寝ました。
数日後、iPhone をお使いの方から連絡をいただきました。アプリを閉じたのに画面が暗いまま戻らない、設定を開いてスライダーを自分で上げた、という内容です。
こちらは同じ一行しか書いていません。それでも、その一行が触っていたものは二つの OS で別のものでした。
同じ setBrightnessAsync が、二つの端末で別のものを触っています
expo-brightness のドキュメントには、この違いがはっきり書かれています。読み落としていたのは私のほうでした。
| 観点 | iOS | Android |
|---|---|---|
setBrightnessAsync が変えるもの | 画面の明るさそのもの | 現在のアクティビティに対する上書き値 |
| アプリを離れたとき | そのまま残ります | システムの値に戻ります |
| 元に戻るきっかけ | 端末がロックされたとき | アプリがフォアグラウンドを離れたとき |
| 必要な権限 | 不要 | アプリ内の明るさだけなら不要 |
Android では、設定した値は「そのアクティビティがフォアグラウンドにある間だけシステムの値を上書きする」という形で効きます。だからホームボタンを押した瞬間に、こちらが何もしなくても明るさは戻ります。私が手元で確認して安心してしまったのは、この自動的な後始末に助けられていただけでした。
iOS には、アプリ単位の明るさという層がありません。書き込んだ値は端末の明るさそのものになり、端末がロックされるまで残ります。アプリを終了しても、ロックされるまでは暗いままなのです。
つまり後始末の必要は、片側にしかありません。そしてその片側を、私は書いていませんでした。
暗くしたのがアプリなら、明るさを返すのもアプリです。 この一文だけは、あれから機能を足すたびに先に思い出すようにしております。
元の明るさは、暗くする前に一度だけ控えます
戻すためには、戻す先の値が要ります。getBrightnessAsync() で読めるのですが、ここに小さな落とし穴がありました。
暗くしたあとにもう一度読むと、返ってくるのは自分が設定した値です。復元用の変数を毎回上書きする書き方にしていると、二度目の呼び出しで「暗い値」が控えとして保存され、戻したつもりが戻らなくなります。
控えるのは、最初の一度だけにします。
import { useRef } from 'react';
import * as Brightness from 'expo-brightness';
const originalRef = useRef<number | null>(null);
async function rememberOnce() {
// すでに控えてあるなら読み直さない(読み直すと自分が下げた値を掴む)
if (originalRef.current !== null) return;
originalRef.current = await Brightness.getBrightnessAsync();
}isAvailableAsync() も併せて呼んでおくと、対応していない環境で機能ごと隠せます。ボタンを出したのに何も起きない、という状態がいちばん不親切だからです。
戻す場所は、画面のアンマウントではなくアプリの状態です
最初のうち、私は復元処理を useEffect のクリーンアップだけに置いていました。画面を閉じれば戻るのだから、それで足りると考えたのです。結果は芳しくありませんでした。
読者の方がアプリを離れる動きは、画面を閉じることだけではありません。ホームに戻る、タスクスイッチャーから終了する、通知から別のアプリへ移る——このときクリーンアップは走らないことがあります。
そこで、明るさの上げ下げを AppState に紐づけました。
import { useCallback, useEffect, useRef } from 'react';
import { AppState, Platform } from 'react-native';
import * as Brightness from 'expo-brightness';
export function useDimScreen(target: number) {
const originalRef = useRef<number | null>(null);
const dimmingRef = useRef(false);
const applyDim = useCallback(async () => {
if (originalRef.current === null) {
originalRef.current = await Brightness.getBrightnessAsync();
}
await Brightness.setBrightnessAsync(target);
}, [target]);
const restore = useCallback(async () => {
const original = originalRef.current;
if (original === null) return;
await Brightness.setBrightnessAsync(original);
}, []);
const start = useCallback(async () => {
if (!(await Brightness.isAvailableAsync())) return;
dimmingRef.current = true;
await applyDim();
}, [applyDim]);
const stop = useCallback(async () => {
dimmingRef.current = false;
await restore();
originalRef.current = null;
}, [restore]);
useEffect(() => {
const sub = AppState.addEventListener('change', next => {
if (!dimmingRef.current) return;
if (next === 'active') {
applyDim();
} else {
// background と inactive の両方で返す。iOS は自動では戻りません
restore();
}
});
return () => {
sub.remove();
if (dimmingRef.current) restore();
};
}, [applyDim, restore]);
return { start, stop };
}inactive も拾っているのは、iOS で通知センターを引き下ろしたときやコントロールセンターを開いたときに、background まで進まず inactive で止まる場合があるからです。ここを取りこぼすと、少し戻したつもりが戻らない状態が残ります。
Android ではこの復元は空振りに近い動きになりますが、害はありません。分岐を増やして条件を二重に持つより、同じ経路で書いておくほうが後から読み返しやすいと感じております。
Android の system 系には手を出さない、と決めました
expo-brightness には、名前に System が入る関数の一群があります。ここは最初に迷ったところでした。
| 関数 | 対応 | 備考 |
|---|---|---|
getSystemBrightnessAsync | Android のみ | 端末全体の明るさを読みます |
setSystemBrightnessAsync | Android のみ・実験的 | 権限が必要で、明るさモードを MANUAL に変えます |
restoreSystemBrightnessAsync | Android のみ | アクティビティの上書きをやめます |
setSystemBrightnessModeAsync | Android のみ | 自動調整の入り切りです |
addBrightnessListener | iOS のみ | Android と web では発火しません |
最初のうち、私は「Android でも iOS と同じ深さまで暗くしたい」と考えて、system 側に手を伸ばしかけました。けれどもこちらを使うには WRITE_SETTINGS が要りますし、setSystemBrightnessAsync は端末全体の明るさを書き換えたうえで、自動調整を切ってしまいます。読者の方が普段どおり自動調整で使っていた設定を、環境音アプリが黙って手動に変えてしまうことになるのです。
権限を一つ増やせば、ストアの説明でもその必要性を書くことになります。アプリの中を暗くしたいだけなら、そこまでの代償を払う理由はありませんでした。
いまは、アプリの前面に出ているあいだにだけ効く範囲へ収める、という線引きにしています。iOS では自分で戻し、Android では OS に戻してもらいます。それぞれの流儀に合わせるほうが、結果として読者の端末を荒らしません。
暗くする機能は、画面を消させない設定と対で考えます
暗くしたい場面は、たいてい「眺めたまま眠りたい」場面です。ところが暗くしただけでは、しばらくすると OS が自動で画面を消します。せっかくの表示が数十秒で消えてしまう、という報告はここから来ます。
そのため私は、暗くするのと同時に自動消灯を止め、フェードが終わったところで解除する形にしました。点けっぱなしは電池を確実に削りますので、解除の条件を先に決めてから入れるほうが安心です。
もう一つ、iOS では addBrightnessListener が使えます。読者の方がコントロールセンターで自分から明るさを変えた場合、こちらが控えていた値はもう古い値です。リスナーで気づいたときに控えを取り直しておくと、戻したときの違和感が減ります。同じ発想の後始末は音のほうにもあり、画面ロックから3分後、expo-audio の環境音は静かに止まっていました に書き残しました。画面が消えてからの挙動を扱うアプリは、どの機能でも「離れたあと」を先に決める必要があるのだと思います。
今夜のうちに確かめられること
実機が二台あれば、十分ほどで確認できます。iPhone で暗くするボタンを押し、ホームに戻り、アプリを終了してから設定アプリの明るさを見てください。スライダーが下がったままなら、復元が走っていません。Android では、ホームに戻った時点で明るさが戻るはずです。
iOS 27 と iPadOS 27 は9月14日に配信されます。明るさのように OS 側の既定へ直接触る箇所は、更新の前に一度動かしておくと落ち着いて迎えられます。手順は iOS 27 が正式版になる前に、予備の iPhone で公開中の Rork アプリを触っておく にまとめてあります。
まずは、いま暗くしている画面から離れたときに何が起きるかを、一度だけ手で試してみていただければと思います。私もそこで気づきました。お読みいただきありがとうございました。