앱에서 접힌 상태 인식

펼친 상태의 대형 디스플레이와 고유한 접힌 상태는 폴더블 기기에서 새로운 사용자 환경을 제공합니다. 앱이 기기의 접힌 상태를 인식하도록 하려면 접힘 및 힌지와 같은 폴더블 기기 창 기능에 API 표시 영역을 제공하는 라이브러리인 Jetpack WindowManager를 사용합니다. 앱이 접힌 상태를 인식하는 경우 접힘 또는 힌지 영역에 중요한 콘텐츠를 배치하지 않고 접힘과 힌지를 자연스러운 구분선으로 사용하도록 레이아웃을 조정할 수 있습니다.

창 정보

Jetpack WindowManager의 WindowInfoTracker 인터페이스는 창 레이아웃 정보를 노출합니다. 인터페이스의 windowLayoutInfo() 메서드는 앱에 폴더블 기기의 접힌 상태를 알려주는 WindowLayoutInfo 데이터 스트림을 반환합니다. WindowInfoTracker getOrCreate() 메서드는 WindowInfoTracker 인스턴스를 만듭니다.

WindowManager는 Kotlin Flow 및 자바 콜백을 사용하여 WindowLayoutInfo 데이터를 수집하도록 지원합니다.

Kotlin Flow

WindowLayoutInfo 데이터 수집을 시작하고 중지하려면 재시작 가능한 수명 주기 인식 코루틴을 사용하면 됩니다. 코루틴의 repeatOnLifecycle 코드 블록은 수명 주기가 STARTED 이상이면 실행되고 수명 주기가 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.
                    }
            }
        }
    }
}

자바 콜백

androidx.window:window-java 종속 항목에 포함된 콜백 호환성 레이어를 사용하면 Kotlin Flow를 사용하지 않고도 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(버전 2 또는 3)를 사용 중인 경우 Observable 또는 Flowable을 사용하여 Kotlin Flow를 사용하지 않고도 WindowLayoutInfo 업데이트를 수집할 수 있는 아티팩트를 사용할 수 있습니다.

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: 기기의 접힌 상태(FLAT 또는 HALF_OPENED)
  • orientation: 접힘 또는 힌지의 방향(HORIZONTAL 또는 VERTICAL)
  • occlusionType: 접힘 또는 힌지가 디스플레이의 일부를 가리는지 여부(NONE 또는 FULL)
  • 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
}

자바

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 클래스를 사용하면 현재 및 최대 창 측정항목을 검색할 수 있습니다. WindowManager WindowMetrics는 API 수준 30에서 도입된 플랫폼 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()
    ...
}

자바

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

다양한 화면 크기 지원도 참고하세요.

추가 리소스

샘플

Codelab