JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
16.26k stars 1.18k forks source link

iPad displayCutoutPadding behaves differently by the screen orientation #4066

Closed paxbun closed 1 month ago

paxbun commented 10 months ago

Describe the bug On iPad, displayCutoutPadding applies zero insets in landspace modes but applies some insets in portrait modes. In the normal portrait mode, there is a top inset; in the upside-down portrait mode, there is a bottom inset.

Affected platforms

Versions

To Reproduce Steps and/or the code snippet to reproduce the behavior:

  1. Generate a Compose project using the Kotlin Multiplatform Wizard.
  2. Edit composeApp/src/iosMain/kotlin/MainViewController.kt as follows. (The button in the example shows/hides the status bar)
    
    import androidx.compose.foundation.background
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.displayCutoutPadding
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.foundation.layout.safeContentPadding
    import androidx.compose.material.Button
    import androidx.compose.material.MaterialTheme
    import androidx.compose.material.Text
    import androidx.compose.runtime.DisposableEffect
    import androidx.compose.runtime.remember
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.interop.LocalUIViewController
    import androidx.compose.ui.window.ComposeUIViewController
    import androidx.compose.ui.zIndex
    import kotlinx.cinterop.ExperimentalForeignApi
    import kotlinx.cinterop.readValue
    import platform.CoreGraphics.CGRectZero
    import platform.UIKit.UIStatusBarAnimation
    import platform.UIKit.UIViewController
    import platform.UIKit.addChildViewController
    import platform.UIKit.didMoveToParentViewController
    import platform.UIKit.willMoveToParentViewController

fun MainViewController() = ComposeUIViewController { val uiViewController = LocalUIViewController.current val rootViewController = uiViewController.view.window?.rootViewController val statusBarController = remember { StatusBarController() } DisposableEffect(rootViewController) { if (rootViewController != null) { with(statusBarController) { rootViewController.addChildViewController(this) rootViewController.view.addSubview(view) didMoveToParentViewController(rootViewController) } } onDispose { with(statusBarController) { willMoveToParentViewController(null) view.removeFromSuperview() } } } MaterialTheme { Box(Modifier.fillMaxSize()) { Box(Modifier.displayCutoutPadding().fillMaxSize().background(Color.Red).zIndex(0.0f)) Box(Modifier.safeContentPadding().fillMaxSize().background(Color.Blue).zIndex(10.0f)) Box(Modifier.safeContentPadding().zIndex(20.0f)) { Button(onClick = { statusBarController.statusBarHidden = !statusBarController.statusBarHidden }) { Text("Click me!") } } } } }

@OptIn(ExperimentalForeignApi::class) class StatusBarController : UIViewController(nibName = null, bundle = null) { init { view.setBounds(CGRectZero.readValue()) }

var statusBarHidden: Boolean = false
    set(value) {
        field = value
        setNeedsStatusBarAppearanceUpdate()
    }

override fun prefersStatusBarHidden(): Boolean = statusBarHidden
override fun preferredStatusBarUpdateAnimation(): UIStatusBarAnimation =
    UIStatusBarAnimation.UIStatusBarAnimationSlide

}

3. Edit `iosApp/iosApp/ContentView.swift` as follows. (displayCutoutPadding does not work without `.ignoresSafeArea(.all)`)
```swift
import UIKit
import SwiftUI
import ComposeApp

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        MainViewControllerKt.MainViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

struct ContentView: View {
    var body: some View {
        ComposeView().ignoresSafeArea(.all) // Compose has own keyboard handler
    }
}
  1. Launch the app on your iPad.

Expected behavior With every screen orientation, displayCutoutPadding must behave the same.

Screenshots

https://github.com/JetBrains/compose-multiplatform/assets/17005454/cc08d043-df50-456a-93c3-cb5d76879a4e

Additional context The current implementation relies on UIViewController.safeAreaInsets, and on iPadOS, safeAreaInsets does not return different values by screen orientation. So, WindowInsets.Companion.displayCutout should check for UIDevice.currentDevice.userInterfaceIdiom and use iosSafeArea.only(WindowInsetsSides.Top) regardless of the orientation on iPad. However, that makes displayCutoutPadding always put some top insets when the status bar is present. This is because safeAreaInsets behaves differently on devices without notches, even on the iPhone SE. The only way to make the value of safeAreaInsets.top 0 is to hide the status bar, as shown in the example above. One way to "fix" this problem would be to ignore safeAreaInsets on iPad and iPhone SE and set a manual additionalSafeAreaInsets.

dima-avdeev-jb commented 10 months ago

Thanks! I will take a look

dima-avdeev-jb commented 10 months ago

Maybe related to this issue: https://github.com/JetBrains/compose-multiplatform/issues/4045

okushnikov commented 2 months ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.