Skip to content

Commit

Permalink
Add crossfade animation during orientation change when used within UI…
Browse files Browse the repository at this point in the history
…Kit hierarchy (#778)

* Add crossfade animation during orientation change

* Force to present with transaction every frame during orientation change transition to avoid timing issues between UIKit logic and Compose render.

* Turn off orientation change logic when inside SwiftUI

* Remove this logic when modally presented

* Update compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt

Co-authored-by: dima.avdeev <[email protected]>

* Fix compilation error

---------

Co-authored-by: dima.avdeev <[email protected]>
  • Loading branch information
elijah-semyonov and dima-avdeev-jb authored Aug 31, 2023
1 parent 55b2de0 commit 8f57b8c
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,19 @@ import kotlin.math.roundToInt
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExportObjCClass
import kotlinx.cinterop.ObjCAction
import kotlinx.cinterop.readValue
import kotlinx.cinterop.useContents
import org.jetbrains.skia.Surface
import org.jetbrains.skiko.SkikoKeyboardEvent
import org.jetbrains.skiko.SkikoPointerEvent
import org.jetbrains.skiko.currentNanoTime
import platform.CoreGraphics.CGAffineTransformIdentity
import platform.CoreGraphics.CGAffineTransformInvert
import platform.CoreGraphics.CGPoint
import platform.CoreGraphics.CGPointMake
import platform.CoreGraphics.CGRectMake
import platform.CoreGraphics.CGSize
import platform.CoreGraphics.CGSizeEqualToSize
import platform.Foundation.*
import platform.UIKit.*
import platform.darwin.NSObject
Expand Down Expand Up @@ -94,6 +99,34 @@ private class AttachedComposeContext(
val scene: ComposeScene,
val view: SkikoUIView,
) {
private var constraints: List<NSLayoutConstraint> = emptyList()
set(value) {
if (field.isNotEmpty()) {
NSLayoutConstraint.deactivateConstraints(field)
}
field = value
NSLayoutConstraint.activateConstraints(value)
}

fun setConstraintsToCenterInView(parentView: UIView, size: CValue<CGSize>) {
size.useContents {
constraints = listOf(
view.centerXAnchor.constraintEqualToAnchor(parentView.centerXAnchor),
view.centerYAnchor.constraintEqualToAnchor(parentView.centerYAnchor),
view.widthAnchor.constraintEqualToConstant(width),
view.heightAnchor.constraintEqualToConstant(height)
)
}
}

fun setConstraintsToFillView(parentView: UIView) {
constraints = listOf(
view.leftAnchor.constraintEqualToAnchor(parentView.leftAnchor),
view.rightAnchor.constraintEqualToAnchor(parentView.rightAnchor),
view.topAnchor.constraintEqualToAnchor(parentView.topAnchor),
view.bottomAnchor.constraintEqualToAnchor(parentView.bottomAnchor)
)
}
fun dispose() {
scene.close()
view.dispose()
Expand All @@ -106,6 +139,7 @@ internal actual class ComposeWindow : UIViewController {

internal lateinit var configuration: ComposeUIViewControllerConfiguration
private val keyboardOverlapHeightState = mutableStateOf(0f)
private var isInsideSwiftUI = false
private val safeAreaState = mutableStateOf(IOSInsets())
private val layoutMarginsState = mutableStateOf(IOSInsets())
private val interopContext = UIKitInteropContext(requestRedraw = {
Expand Down Expand Up @@ -164,7 +198,10 @@ internal actual class ComposeWindow : UIViewController {
}

private val density: Density
get() = Density(attachedComposeContext?.view?.contentScaleFactor?.toFloat() ?: 1f, fontScale)
get() = Density(
attachedComposeContext?.view?.contentScaleFactor?.toFloat() ?: 1f,
fontScale
)

private lateinit var content: @Composable () -> Unit

Expand Down Expand Up @@ -317,11 +354,74 @@ internal actual class ComposeWindow : UIViewController {
context.view.needRedraw()
}

override fun viewWillTransitionToSize(
size: CValue<CGSize>,
withTransitionCoordinator: UIViewControllerTransitionCoordinatorProtocol
) {
super.viewWillTransitionToSize(size, withTransitionCoordinator)

if (isInsideSwiftUI || presentingViewController != null) {
// SwiftUI will do full layout and scene constraints update on each frame of orientation change animation
// This logic is not needed

// When presented modally, UIKit performs non-trivial hierarchy update durting orientation change,
// its logic is not feasible to integrate into
return
}

val attachedComposeContext = attachedComposeContext ?: return

// Happens during orientation change from LandscapeLeft to LandscapeRight, for example
val isSameSizeTransition = view.frame.useContents {
CGSizeEqualToSize(size, this.size.readValue())
}
if (isSameSizeTransition) {
return
}

val startSnapshotView =
attachedComposeContext.view.snapshotViewAfterScreenUpdates(false) ?: return

startSnapshotView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(startSnapshotView)
size.useContents {
NSLayoutConstraint.activateConstraints(
listOf(
startSnapshotView.widthAnchor.constraintEqualToConstant(height),
startSnapshotView.heightAnchor.constraintEqualToConstant(width),
startSnapshotView.centerXAnchor.constraintEqualToAnchor(view.centerXAnchor),
startSnapshotView.centerYAnchor.constraintEqualToAnchor(view.centerYAnchor)
)
)
}

attachedComposeContext.view.isForcedToPresentWithTransactionEveryFrame = true

attachedComposeContext.setConstraintsToCenterInView(view, size)
attachedComposeContext.view.transform = withTransitionCoordinator.targetTransform

view.layoutIfNeeded()

withTransitionCoordinator.animateAlongsideTransition(
animation = {
startSnapshotView.alpha = 0.0
startSnapshotView.transform =
CGAffineTransformInvert(withTransitionCoordinator.targetTransform)
attachedComposeContext.view.transform = CGAffineTransformIdentity.readValue()
},
completion = {
startSnapshotView.removeFromSuperview()
attachedComposeContext.setConstraintsToFillView(view)
attachedComposeContext.view.isForcedToPresentWithTransactionEveryFrame = false
}
)
}

override fun viewWillAppear(animated: Boolean) {
super.viewWillAppear(animated)

isInsideSwiftUI = checkIfInsideSwiftUI()
attachComposeIfNeeded()

configuration.delegate.viewWillAppear(animated)
}

Expand Down Expand Up @@ -402,15 +502,6 @@ internal actual class ComposeWindow : UIViewController {
skikoUIView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(skikoUIView)

NSLayoutConstraint.activateConstraints(
listOf(
skikoUIView.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor),
skikoUIView.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor),
skikoUIView.topAnchor.constraintEqualToAnchor(view.topAnchor),
skikoUIView.bottomAnchor.constraintEqualToAnchor(view.bottomAnchor)
)
)

val inputServices = UIKitTextInputService(
showSoftwareKeyboard = {
skikoUIView.showScreenKeyboard()
Expand Down Expand Up @@ -501,7 +592,10 @@ internal actual class ComposeWindow : UIViewController {
override fun pointInside(point: CValue<CGPoint>, event: UIEvent?): Boolean =
point.useContents {
val hitsInteropView = attachedComposeContext?.scene?.mainOwner?.hitInteropView(
pointerPosition = Offset((x * density.density).toFloat(), (y * density.density).toFloat()),
pointerPosition = Offset(
(x * density.density).toFloat(),
(y * density.density).toFloat()
),
isTouchEvent = true,
) ?: false

Expand Down Expand Up @@ -560,11 +654,33 @@ internal actual class ComposeWindow : UIViewController {

attachedComposeContext =
AttachedComposeContext(scene, skikoUIView).also {
it.setConstraintsToFillView(view)
updateLayout(it)
}
}
}

private fun UIViewController.checkIfInsideSwiftUI(): Boolean {
var parent = parentViewController

while (parent != null) {
val isUIHostingController = parent.`class`()?.let {
val className = NSStringFromClass(it)
// SwiftUI UIHostingController has mangled name depending on generic instantiation type,
// It always contains UIHostingController substring though
return className.contains("UIHostingController")
} ?: false

if (isUIHostingController) {
return true
}

parent = parent.parentViewController
}

return false
}

private fun UIUserInterfaceStyle.asComposeSystemTheme(): SystemTheme {
return when (this) {
UIUserInterfaceStyle.UIUserInterfaceStyleLight -> SystemTheme.Light
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ internal class MetalRedrawer(
// Semaphore for preventing command buffers count more than swapchain size to be scheduled/executed at the same time
private val inflightSemaphore = dispatch_semaphore_create(metalLayer.maximumDrawableCount.toLong())

var isForcedToPresentWithTransactionEveryFrame = false

var maximumFramesPerSecond: NSInteger
get() = caDisplayLink?.preferredFramesPerSecond ?: 0
set(value) {
Expand Down Expand Up @@ -287,12 +289,14 @@ internal class MetalRedrawer(
surface.flushAndSubmit()

val caTransactionCommands = retrieveCATransactionCommands()
metalLayer.presentsWithTransaction = caTransactionCommands.isNotEmpty()
val presentsWithTransaction = isForcedToPresentWithTransactionEveryFrame || caTransactionCommands.isNotEmpty()

metalLayer.presentsWithTransaction = presentsWithTransaction

val commandBuffer = queue.commandBuffer()!!
commandBuffer.label = "Present"

if (caTransactionCommands.isEmpty()) {
if (!presentsWithTransaction) {
// If there are no pending changes in UIKit interop, present the drawable ASAP
commandBuffer.presentDrawable(metalDrawable)
}
Expand All @@ -303,7 +307,7 @@ internal class MetalRedrawer(
}
commandBuffer.commit()

if (caTransactionCommands.isNotEmpty()) {
if (presentsWithTransaction) {
// If there are pending changes in UIKit interop, [waitUntilScheduled](https://developer.apple.com/documentation/metal/mtlcommandbuffer/1443036-waituntilscheduled) is called
// to ensure that transaction is available
commandBuffer.waitUntilScheduled()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import androidx.compose.ui.platform.IOSSkikoInput
import androidx.compose.ui.platform.SkikoUITextInputTraits
import androidx.compose.ui.platform.TextActions
import kotlinx.cinterop.*
import org.jetbrains.skia.Point
import org.jetbrains.skia.Rect
import platform.CoreGraphics.*
import platform.Foundation.*
Expand Down Expand Up @@ -122,6 +121,8 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol {

fun needRedraw() = _redrawer.needRedraw()

var isForcedToPresentWithTransactionEveryFrame by _redrawer::isForcedToPresentWithTransactionEveryFrame

/**
* Show copy/paste text menu
* @param targetRect - rectangle of selected text area
Expand Down

0 comments on commit 8f57b8c

Please sign in to comment.