讓應用程式適用折疊式裝置

在折疊式裝置上,未折疊的大型螢幕和獨特的折疊狀態可以提供新的使用者體驗。為了讓應用程式適用折疊式裝置,您可以使用 Jetpack WindowManager 程式庫,它可為折疊式裝置的視窗功能 (例如折疊或轉軸) 提供 API 介面。適用折疊式裝置的應用程式可以調整版面配置,避免在折疊和轉軸處放置重要內容,並能運用折疊及轉軸作為自然分隔符。

視窗資訊

Jetpack WindowManager 的 WindowInfoTracker 介面可以曝露視窗版面配置資訊。此介面的 windowLayoutInfo() 方法會回傳 WindowLayoutInfo 串流資料,藉此將折疊式裝置的折疊狀態傳送給應用程式。WindowInfoTracker getOrCreate() 方法會建立 WindowInfoTracker 的執行個體。

WindowManager 支援使用 Kotlin 資料流及 Java 回呼收集 WindowLayoutInfo 資料。

Kotlin 資料流

若要開始及停止收集 WindowLayoutInfo資料,可使用可重新啟動的生命週期感知協同程式,當生命週期至少 STARTED 時便會執行 repeatOnLifecycle 程式碼區塊,而當生命週期為 STOPPED 時便會停止。當生命週期再度為 STARTED 時,系統會自動重新開始執行程式碼區塊。在以下範例中,程式碼區塊會收集並使用 WindowLayoutInfo 資料:

class DisplayFeaturesActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDisplayFeaturesBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityDisplayFeaturesBinding.inflate(layoutInflater)
        setContentView(binding.root)

        lifecycleScope.launch(Dispatchers.Main) {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                WindowInfoTracker.getOrCreate(this@DisplayFeaturesActivity)
                    .windowLayoutInfo(this@DisplayFeaturesActivity)
                    .collect { newLayoutInfo ->
                        // Use newLayoutInfo to update the layout.
                    }
            }
        }
    }
}

Java 回呼

androidx.window:window-java 依附元件內附的回呼相容性層可在不使用 Kotlin 資料流的情況下收集 WindowLayoutInfo 更新資訊。構件包含 WindowInfoTrackerCallbackAdapter 類別,這個類別會自動調整 WindowInfoTracker 以支援註冊 (及取消註冊) 回呼,以接收 WindowLayoutInfo 的更新,例如:

public class SplitLayoutActivity extends AppCompatActivity {

    private WindowInfoTrackerCallbackAdapter windowInfoTracker;
    private ActivitySplitLayoutBinding binding;
    private final LayoutStateChangeCallback layoutStateChangeCallback =
            new LayoutStateChangeCallback();

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);

       binding = ActivitySplitLayoutBinding.inflate(getLayoutInflater());
       setContentView(binding.getRoot());

       windowInfoTracker =
                new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
   }

   @Override
   protected void onStart() {
       super.onStart();
       windowInfoTracker.addWindowLayoutInfoListener(
                this, Runnable::run, layoutStateChangeCallback);
   }

   @Override
   protected void onStop() {
       super.onStop();
       windowInfoTracker
           .removeWindowLayoutInfoListener(layoutStateChangeCallback);
   }

   class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
       @Override
       public void accept(WindowLayoutInfo newLayoutInfo) {
           SplitLayoutActivity.this.runOnUiThread( () -> {
               // Use newLayoutInfo to update the layout.
           });
       }
   }
}

RxJava 支援

如果您已在使用 RxJava (版本 23),您可以利用可讓您使用 ObservableFlowable 的特定構件以收集 WindowLayoutInfo 更新,無需使用 Kotlin Flow。

androidx.window:window-rxjava2androidx.window:window-rxjava3 依附元件提供的相容性層包含 WindowInfoTracker#windowLayoutInfoFlowable()WindowInfoTracker#windowLayoutInfoObservable() 方法,可讓應用程式接收 WindowLayoutInfo 更新,例如:

class RxActivity: AppCompatActivity {

    private lateinit var binding: ActivityRxBinding

    private var disposable: Disposable? = null
    private lateinit var observable: Observable<WindowLayoutInfo>

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);

       binding = ActivitySplitLayoutBinding.inflate(getLayoutInflater());
       setContentView(binding.getRoot());

        // Create a new observable
        observable = WindowInfoTracker.getOrCreate(this@RxActivity)
            .windowLayoutInfoObservable(this@RxActivity)
   }

   @Override
   protected void onStart() {
       super.onStart();

        // Subscribe to receive WindowLayoutInfo updates
        disposable?.dispose()
        disposable = observable
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe { newLayoutInfo ->
            // Use newLayoutInfo to update the layout
        }
   }

   @Override
   protected void onStop() {
       super.onStop();

        // Dispose the WindowLayoutInfo observable
        disposable?.dispose()
   }
}

折疊式裝置螢幕功能

Jetpack WindowManager 的 WindowLayoutInfo 類別可用 DisplayFeature 元素清單形式提供顯示視窗功能。

FoldingFeature 是一種 DisplayFeature,可以提供折疊式裝置螢幕相關資訊,包括:

  • state:裝置折疊狀態,可為 FLATHALF_OPENED
  • orientation:折疊或轉軸方向,可為 HORIZONTALVERTICAL
  • occlusionType:折疊或轉軸是否會擋住部分螢幕,可為 NONEFULL
  • isSeparating:折疊或轉軸是否會建立兩個邏輯顯示區域,可為 true 或 false

HALF_OPENED 的折疊式裝置會一律回報 isSeparating 為 true,因為裝置螢幕會分割成兩個顯示區域。如果雙螢幕裝置的應用程式橫跨兩個螢幕,則 isSeparating 也一律為 true。

FoldingFeature bounds 屬性 (沿用 DisplayFeature) 代表折疊功能的矩形界框,如折疊或轉軸。用此界框即可按照這項功能在螢幕上置放元素。

使用 FoldingFeature state 判斷裝置折疊狀態為桌面模式 (水平折疊) 或書籍模式 (垂直折疊),再根據此資訊自訂應用程式版面配置,範例如下:

Kotlin

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    lifecycleScope.launch(Dispatchers.Main) {
        // The block passed to repeatOnLifecycle is executed when the lifecycle
        // is at least STARTED and is cancelled when the lifecycle is STOPPED.
        // It automatically restarts the block when the lifecycle is STARTED again.
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            // Safely collects from windowInfoRepo when the lifecycle is STARTED
            // and stops collection when the lifecycle is STOPPED
            WindowInfoTracker.getOrCreate(this@MainActivity)
                .windowLayoutInfo(this@MainActivity)
                .collect { layoutInfo ->
                    // New posture information
                    val foldingFeature = layoutInfo.displayFeatures
                        .filterIsInstance()
                        .firstOrNull()
                    when {
                            isTableTopPosture(foldingFeature) ->
                                enterTabletopMode(foldingFeature)
                            isBookPosture(foldingFeature) ->
                                enterBookMode(foldingFeature)
                            isSeparating(foldingFeature) ->
                            // Dual-screen device
                            if (foldingFeature.orientation == HORIZONTAL) {
                                enterTabletopMode(foldingFeature)
                            } else {
                                enterBookMode(foldingFeature)
                            }
                            else ->
                                enterNormalMode()
                        }
                }

        }
    }
}

@OptIn(ExperimentalContracts::class)
fun isTableTopPosture(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
            foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
}

@OptIn(ExperimentalContracts::class)
fun isBookPosture(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
            foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
}

@OptIn(ExperimentalContracts::class)
fun isSeparating(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating
}

Java

private WindowInfoTrackerCallbackAdapter windowInfoTracker;
private final LayoutStateChangeCallback layoutStateChangeCallback =
                new LayoutStateChangeCallback();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    ...
    windowInfoTracker =
            new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
}

@Override
protected void onStart() {
    super.onStart();
    windowInfoTracker.addWindowLayoutInfoListener(
            this, Runnable::run, layoutStateChangeCallback);
}

@Override
protected void onStop() {
    super.onStop();
    windowInfoTracker.removeWindowLayoutInfoListener(layoutStateChangeCallback);
}

class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
    @Override
    public void accept(WindowLayoutInfo newLayoutInfo) {
        // Use newLayoutInfo to update the Layout
        List<DisplayFeature> displayFeatures = newLayoutInfo.getDisplayFeatures();
        for (DisplayFeature feature : displayFeatures) {
            if (feature instanceof FoldingFeature) {
                if (isTableTopPosture((FoldingFeature) feature)) {
                    enterTabletopMode(feature);
                } else if (isBookPosture((FoldingFeature) feature)) {
                    enterBookMode(feature);
                } else if (isSeparating((FoldingFeature) feature)) {
                    // Dual-screen device
                    if (((FoldingFeature) feature).getOrientation() ==
                              FoldingFeature.Orientation.HORIZONTAL) {
                        enterTabletopMode(feature);
                    } else {
                        enterBookMode(feature);
                    }
                } else {
                    enterNormalMode();
                }
            }
        }
    }
}

private boolean isTableTopPosture(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.HALF_OPENED) &&
           (foldFeature.getOrientation() == FoldingFeature.Orientation.HORIZONTAL);
}

private boolean isBookPosture(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.HALF_OPENED) &&
           (foldFeature.getOrientation() == FoldingFeature.Orientation.VERTICAL);
}

private boolean isSeparating(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.FLAT) &&
           (foldFeature.isSeparating() == true);
}

在雙螢幕裝置上,即使 FoldingFeature 狀態為 FLAT,也請使用為桌面及書籍模式設計的版面配置。

如果 isSeparating 為 true,請勿在折疊或轉軸附近置放 UI 控制項,因為這樣無法輕鬆使用控制項。使用 occlusionType 決定是否要把內容置放在折疊功能 bounds 範圍內。

視窗大小變化

應用程式的顯示區域可能會隨著裝置設定一起變化,例如裝置處於折疊、未折疊或旋轉狀態;或是多視窗模式中,有某個視窗重新調整了大小等。

您可以利用 Jetpack WindowManager WindowMetricsCalculator 類別擷取目前和最大的視窗指標。如同 API 級別 30 所導入的 WindowManager WindowMetrics 平台,WindowManager WindowMetrics 也能提供視窗界框,但是 API 可以回溯相容到 API 級別 14。

以活動的 onCreate()onConfigurationChanged() 方法使用 WindowMetrics,以便用目前的視窗大小設定應用程式版面配置,範例如下:

Kotlin

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    val windowMetrics = WindowMetricsCalculator.getOrCreate()
        .computeCurrentWindowMetrics(this@MainActivity)
    val bounds = windowMetrics.getBounds()
    ...
}

Java

@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    final WindowMetrics windowMetrics = WindowMetricsCalculator.getOrCreate()
        .computeCurrentWindowMetrics(this);
    final Rect bounds = windowMetrics.getBounds();
    ...
}

也請您參閱支援不同的螢幕大小

其他資源

範例

程式碼研究室