앱에서 접힌 상태 인식

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

기기가 탁자 모드나 책 모드와 같은 구성을 지원하는지 여부를 파악하면 다양한 레이아웃을 지원하거나 특정 기능을 제공하는 것에 관한 결정을 내릴 수 있습니다.

창 정보

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

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

Kotlin 흐름

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.
                    }
            }
        }
    }
}

Java 콜백

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 of 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에서 상속됨)은 접힘 또는 힌지와 같은 접기 기능의 경계 직사각형을 나타냅니다. 경계는 접기 기능을 기준으로 화면에 요소를 배치하는 데 사용할 수 있습니다.

Kotlin

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    lifecycleScope.launch(Dispatchers.Main) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            // Safely collects from WindowInfoTracker 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<FoldingFeature>()
                        .firstOrNull()
                    // Use information from the foldingFeature object.
                }

        }
    }
}

자바

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) {
                // Use information from the feature object.
            }
        }
    }
}

탁자 모드 자세

FoldingFeature 객체에 포함된 정보를 사용하여 앱은 휴대전화가 표면에 있고 힌지가 수평 위치에 있으며 폴더블 화면이 절반 정도 열려 있는 탁자 등의 상태를 지원할 수 있습니다.

탁자 모드는 사용자가 휴대전화를 손에 쥐고 있지 않은 상태에서 휴대전화를 조작할 수 있는 편리함을 제공합니다. 탁자 모드는 미디어를 보고, 사진을 찍고, 영상 통화를 할 때 적합합니다.

그림 1. 탁자 모드의 동영상 플레이어 앱

FoldingFeature.StateFoldingFeature.Orientation를 사용하여 기기가 탁자 모드인지 확인할 수 있습니다.

Kotlin

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

자바

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

기기가 탁자 모드임을 알면 그에 따라 앱 레이아웃을 업데이트합니다. 미디어 앱의 경우 일반적으로 스크롤 없이 볼 수 있는 부분에 재생을 배치하고, 핸즈프리 보기 또는 청취 환경을 위해 위치 지정 컨트롤과 보조 콘텐츠를 바로 아래에 배치하는 것을 의미합니다.

Android 15 (API 수준 35) 이상에서는 동기식 API를 호출하여 기기의 현재 상태와 관계없이 기기가 테이블탑 상태를 지원하는지 감지할 수 있습니다.

API는 기기에서 지원하는 상태 목록을 제공합니다. 목록에 탁자 상태가 포함된 경우 상태를 지원하도록 앱 레이아웃을 분할하고 앱 UI에서 탁자 및 전체 화면 레이아웃의 A/B 테스트를 실행할 수 있습니다.

Kotlin

if (WindowSdkExtensions.getInstance().extensionsVersion >= 6) {
    val postures = WindowInfoTracker.getOrCreate(context).supportedPostures
    if (postures.contains(TABLE_TOP)) {
        // Device supports tabletop posture.
   }
}

자바

if (WindowSdkExtensions.getInstance().getExtensionVersion() >= 6) {
    List<SupportedPosture> postures = WindowInfoTracker.getOrCreate(context).getSupportedPostures();
    if (postures.contains(SupportedPosture.TABLETOP)) {
        // Device supports tabletop posture.
    }
}

도서 상태

또 다른 고유한 폴더블 기능은 기기가 반쯤 열려 있고 힌지가 수직인 책 모드입니다. 책 상태는 eBook을 읽을 때 좋습니다. 책 모드는 대형 폴더블 화면에 열린 2페이지 레이아웃을 제본된 책처럼 사용하여 실제 책을 읽는 듯한 경험을 제공합니다.

핸즈프리로 사진을 찍을 때 다른 가로세로 비율을 캡처하려는 경우 사진에도 사용할 수 있습니다.

탁자 상태에 사용된 것과 동일한 기법으로 책 상태를 구현합니다. 유일한 차이점은 코드에서 접기 기능 방향이 가로가 아닌 세로인지 확인해야 한다는 것입니다.

Kotlin

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

자바

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

창 크기 변경

기기 구성이 변경되면(예: 기기가 접히거나 펼쳐지거나, 회전되거나, 멀티 윈도우 모드에서 창의 크기가 조절되는 경우) 앱의 디스플레이 영역이 변경될 수 있습니다.

Jetpack WindowManager의 WindowMetricsCalculator 클래스를 사용하면 현재 및 최대 창 측정항목을 검색할 수 있습니다. WindowManager WindowMetrics는 API 수준 30에서 도입된 플랫폼 WindowMetrics와 마찬가지로 창 경계를 제공하지만 API는 API 수준 14까지 하위 호환됩니다.

창 크기 클래스 사용을 참고하세요.

추가 리소스

샘플

  • Jetpack WindowManager: Jetpack WindowManager 라이브러리 사용 방법의 예
  • Jetcaster : Compose를 사용한 탁자 모드 구현

Codelab