Skip to content

Instantly share code, notes, and snippets.

@Qw4z1
Last active September 28, 2023 03:45
Show Gist options
  • Save Qw4z1/0bfc2bfa7c4ddf046984ed71ac46dcdc to your computer and use it in GitHub Desktop.
Save Qw4z1/0bfc2bfa7c4ddf046984ed71ac46dcdc to your computer and use it in GitHub Desktop.
Kotlin Compose Multiplatform currently doesn't support UIViewControllers with transparent background. Instead it defaults to white background. This creates problems when stacking UIViewControllers on top of each other. Mostly created by @cyberhenoch and @JeroenFlietstra.
package components
import androidx.compose.runtime.Composable
import platform.UIKit.UIColor
@Composable
fun SampleUsageView(
content: @Composable () -> Unit
): () -> Unit {
// Wraps the composable in a UIViewController and sets the background to .clearColor.
TransparentComposeUIViewController(content).apply {
view.backgroundColor = UIColor.clearColor
view.opaque = false
}
}
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
package example
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.compose.runtime.Composable
import androidx.compose.ui.ComposeScene
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.toCompose
import androidx.compose.ui.platform.Platform
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.toDpRect
import kotlinx.coroutines.CoroutineDispatcher
import org.jetbrains.skia.Canvas
import org.jetbrains.skia.Color
import org.jetbrains.skia.Point
import org.jetbrains.skiko.*
import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent
internal class TransparentComposeLayer(
internal val layer: SkiaLayer,
platform: Platform,
private val input: SkikoInput,
) {
private var isDisposed = false
// Should be set to an actual value by ComposeWindow implementation
private var density = Density(1f)
inner class ComponentImpl : SkikoView {
override val input = this@TransparentComposeLayer.input
override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
canvas.clear(Color.TRANSPARENT)
scene.render(canvas, nanoTime)
}
override fun onKeyboardEvent(event: SkikoKeyboardEvent) {
if (isDisposed) return
scene.sendKeyEvent(KeyEvent(event))
}
@OptIn(ExperimentalComposeUiApi::class)
override fun onPointerEvent(event: SkikoPointerEvent) {
if (supportsMultitouch) {
onPointerEventWithMultitouch(event)
} else {
// macos and web don't work properly when using onPointerEventWithMultitouch
onPointerEventNoMultitouch(event)
}
}
@OptIn(ExperimentalComposeUiApi::class)
private fun onPointerEventWithMultitouch(event: SkikoPointerEvent) {
val scale = density.density
scene.sendPointerEvent(
eventType = event.kind.toCompose(),
pointers = event.pointers.map {
ComposeScene.Pointer(
id = PointerId(it.id),
position = Offset(
x = it.x.toFloat() * scale,
y = it.y.toFloat() * scale,
),
pressed = it.pressed,
type = it.device.toCompose(),
pressure = it.pressure.toFloat(),
)
},
timeMillis = event.timestamp,
nativeEvent = event,
)
}
private fun onPointerEventNoMultitouch(event: SkikoPointerEvent) {
val scale = density.density
scene.sendPointerEvent(
eventType = event.kind.toCompose(),
scrollDelta = event.getScrollDelta(),
position = Offset(
x = event.x.toFloat() * scale,
y = event.y.toFloat() * scale,
),
timeMillis = currentMillis(),
type = PointerType.Mouse,
nativeEvent = event,
)
}
}
private val view = ComponentImpl()
init {
layer.skikoView = view
}
private val scene = ComposeScene(
coroutineContext = getMainDispatcher(),
platform = platform,
density = density,
invalidate = layer::needRedraw,
)
fun setDensity(newDensity: Density) {
density = newDensity
scene.density = newDensity
}
fun dispose() {
check(!isDisposed)
layer.detach()
scene.close()
_initContent = null
isDisposed = true
}
fun setSize(width: Int, height: Int) {
scene.constraints = Constraints(maxWidth = width, maxHeight = height)
layer.needRedraw()
}
fun getActiveFocusRect(): DpRect? {
val focusRect = scene.mainOwner?.focusOwner?.getFocusRect() ?: return null
return focusRect.toDpRect(density)
}
fun hitInteropView(point: Point, isTouchEvent: Boolean): Boolean =
scene.mainOwner?.hitInteropView(
pointerPosition = Offset(point.x * density.density, point.y * density.density),
isTouchEvent = isTouchEvent,
) ?: false
fun setContent(
onPreviewKeyEvent: (ComposeKeyEvent) -> Boolean = { false },
onKeyEvent: (ComposeKeyEvent) -> Boolean = { false },
content: @Composable () -> Unit,
) {
// If we call it before attaching, everything probably will be fine,
// but the first composition will be useless, as we set density=1
// (we don't know the real density if we have unattached component)
_initContent = {
scene.setContent(
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
content = content,
)
}
initContent()
}
private var _initContent: (() -> Unit)? = null
private fun initContent() {
// TODO: do we need isDisplayable on SkiaLyer?
// if (layer.isDisplayable) {
_initContent?.invoke()
_initContent = null
// }
}
}
internal fun getMainDispatcher(): CoroutineDispatcher = SkikoDispatchers.Main
private fun currentMillis() = (currentNanoTime() / 1E6).toLong()
internal val supportsMultitouch: Boolean = true
internal fun SkikoPointerEvent.getScrollDelta(): Offset {
return this.takeIf {
it.kind == SkikoPointerEventKind.SCROLL
}?.let {
Offset(it.deltaX.toFloat(), it.deltaY.toFloat())
} ?: Offset.Zero
}
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
@file:OptIn(ExperimentalForeignApi::class)
package example
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.LocalSystemTheme
import androidx.compose.ui.SystemTheme
import androidx.compose.ui.createSkiaLayer
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.interop.LocalLayerContainer
import androidx.compose.ui.interop.LocalUIViewController
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.uikit.*
import androidx.compose.ui.unit.*
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ExportObjCClass
import kotlinx.cinterop.ObjCAction
import kotlinx.cinterop.useContents
import org.jetbrains.skiko.SkikoUIView
import org.jetbrains.skiko.TextActions
import org.jetbrains.skiko.ios.SkikoUITextInputTraits
import platform.CoreGraphics.CGPointMake
import platform.CoreGraphics.CGRectMake
import platform.Foundation.*
import platform.UIKit.*
import platform.darwin.NSObject
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
import kotlin.math.roundToInt
import kotlin.native.runtime.GC
import kotlin.native.runtime.NativeRuntimeApi
private val uiContentSizeCategoryToFontScaleMap = mapOf(
UIContentSizeCategoryExtraSmall to 0.8f,
UIContentSizeCategorySmall to 0.85f,
UIContentSizeCategoryMedium to 0.9f,
UIContentSizeCategoryLarge to 1f, // default preference
UIContentSizeCategoryExtraLarge to 1.1f,
UIContentSizeCategoryExtraExtraLarge to 1.2f,
UIContentSizeCategoryExtraExtraExtraLarge to 1.3f,
// These values don't work well if they match scale shown by
// Text Size control hint, because iOS uses non-linear scaling
// calculated by UIFontMetrics, while Compose uses linear.
UIContentSizeCategoryAccessibilityMedium to 1.4f, // 160% native
UIContentSizeCategoryAccessibilityLarge to 1.5f, // 190% native
UIContentSizeCategoryAccessibilityExtraLarge to 1.6f, // 235% native
UIContentSizeCategoryAccessibilityExtraExtraLarge to 1.7f, // 275% native
UIContentSizeCategoryAccessibilityExtraExtraExtraLarge to 1.8f, // 310% native
// UIContentSizeCategoryUnspecified
)
fun TransparentComposeUIViewController(content: @Composable () -> Unit): UIViewController =
TransparentComposeUIViewController(configure = {}, content = content)
fun TransparentComposeUIViewController(
configure: ComposeUIViewControllerConfiguration.() -> Unit = {},
content: @Composable () -> Unit,
): UIViewController =
TransparentComposeWindow().apply {
configuration = ComposeUIViewControllerConfiguration()
.apply(configure)
setContent(content)
}
private class AttachedComposeContext(
val composeLayer: TransparentComposeLayer,
val skiaLayer: org.jetbrains.skiko.SkiaLayer,
val view: SkikoUIView,
val inputTraits: SkikoUITextInputTraits,
val platform: androidx.compose.ui.platform.Platform,
) {
fun dispose() {
composeLayer.dispose()
view.removeFromSuperview()
}
}
@OptIn(InternalComposeApi::class, ExperimentalForeignApi::class, BetaInteropApi::class)
@ExportObjCClass
class TransparentComposeWindow @OverrideInit constructor() : UIViewController(
nibName = null,
bundle = null
) {
internal lateinit var configuration: ComposeUIViewControllerConfiguration
private val keyboardOverlapHeightState = mutableStateOf(0f)
private val safeAreaState = mutableStateOf(IOSInsets())
private val layoutMarginsState = mutableStateOf(IOSInsets())
/*
* Initial value is arbitarily chosen to avoid propagating invalid value logic
* It's never the case in real usage scenario to reflect that in type system
*/
private val interfaceOrientationState = mutableStateOf(
InterfaceOrientation.Portrait,
)
private val systemTheme = mutableStateOf(
traitCollection.userInterfaceStyle.asComposeSystemTheme(),
)
/*
* On iOS >= 13.0 interfaceOrientation will be deduced from [UIWindowScene] of [UIWindow]
* to which our [ComposeWindow] is attached.
* It's never UIInterfaceOrientationUnknown, if accessed after owning [UIWindow] was made key and visible:
* https://developer.apple.com/documentation/uikit/uiwindow/1621601-makekeyandvisible?language=objc
*/
private val currentInterfaceOrientation: InterfaceOrientation?
get() {
// Flag for checking which API to use
// Modern: https://developer.apple.com/documentation/uikit/uiwindowscene/3198088-interfaceorientation?language=objc
// Deprecated: https://developer.apple.com/documentation/uikit/uiapplication/1623026-statusbarorientation?language=objc
val supportsWindowSceneApi =
NSProcessInfo.processInfo.operatingSystemVersion.useContents {
majorVersion >= 13
}
return if (supportsWindowSceneApi) {
view.window?.windowScene?.interfaceOrientation?.let {
InterfaceOrientation.getByRawValue(it)
}
} else {
InterfaceOrientation.getByRawValue(UIApplication.sharedApplication.statusBarOrientation)
}
}
private val fontScale: Float
get() {
val contentSizeCategory =
traitCollection.preferredContentSizeCategory ?: UIContentSizeCategoryUnspecified
return uiContentSizeCategoryToFontScaleMap[contentSizeCategory] ?: 1.0f
}
private val density: Density
get() = Density(attachedComposeContext?.skiaLayer?.contentScale ?: 1f, fontScale)
private lateinit var content: @Composable () -> Unit
private var attachedComposeContext: AttachedComposeContext? = null
@OptIn(BetaInteropApi::class)
private val keyboardVisibilityListener = object : NSObject() {
@Suppress("unused")
@ObjCAction
fun keyboardWillShow(arg: NSNotification) {
val keyboardInfo = arg.userInfo!!["UIKeyboardFrameEndUserInfoKey"] as NSValue
val keyboardHeight = keyboardInfo.CGRectValue().useContents { size.height }
val screenHeight = UIScreen.mainScreen.bounds.useContents { size.height }
val composeViewBottomY = UIScreen.mainScreen.coordinateSpace.convertPoint(
point = CGPointMake(0.0, view.frame.useContents { size.height }),
fromCoordinateSpace = view.coordinateSpace,
).useContents { y }
val bottomIndent = screenHeight - composeViewBottomY
if (bottomIndent < keyboardHeight) {
keyboardOverlapHeightState.value = (keyboardHeight - bottomIndent).toFloat()
}
val composeLayer = attachedComposeContext?.composeLayer ?: return
if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) {
val focusedRect = composeLayer.getActiveFocusRect()
if (focusedRect != null) {
updateViewBounds(
offsetY = calcFocusedLiftingY(focusedRect, keyboardHeight),
)
}
}
}
@Suppress("unused")
@ObjCAction
fun keyboardWillHide(arg: NSNotification) {
keyboardOverlapHeightState.value = 0f
if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) {
updateViewBounds(offsetY = 0.0)
}
}
private fun calcFocusedLiftingY(focusedRect: DpRect, keyboardHeight: Double): Double {
val viewHeight = attachedComposeContext?.view?.frame?.useContents {
size.height
} ?: 0.0
val hiddenPartOfFocusedElement: Double =
keyboardHeight - viewHeight + focusedRect.bottom.value
return if (hiddenPartOfFocusedElement > 0) {
// If focused element is partially hidden by the keyboard, we need to lift it upper
val focusedTopY = focusedRect.top.value
val isFocusedElementRemainsVisible = hiddenPartOfFocusedElement < focusedTopY
if (isFocusedElementRemainsVisible) {
// We need to lift focused element to be fully visible
hiddenPartOfFocusedElement
} else {
// In this case focused element height is bigger than remain part of the screen after showing the keyboard.
// Top edge of focused element should be visible. Same logic on Android.
maxOf(focusedTopY, 0f).toDouble()
}
} else {
// Focused element is not hidden by the keyboard.
0.0
}
}
private fun updateViewBounds(offsetX: Double = 0.0, offsetY: Double = 0.0) {
val (width, height) = getViewFrameSize()
view.layer.setBounds(
CGRectMake(
x = offsetX,
y = offsetY,
width = width.toDouble(),
height = height.toDouble(),
),
)
}
}
@OptIn(BetaInteropApi::class)
@Suppress("unused")
@ObjCAction
fun viewSafeAreaInsetsDidChange() {
// super.viewSafeAreaInsetsDidChange() // TODO: call super after Kotlin 1.8.20
view.safeAreaInsets.useContents {
safeAreaState.value = IOSInsets(
top = top.dp,
bottom = bottom.dp,
left = left.dp,
right = right.dp,
)
}
view.directionalLayoutMargins.useContents {
layoutMarginsState.value = IOSInsets(
top = top.dp,
bottom = bottom.dp,
left = leading.dp,
right = trailing.dp,
)
}
}
override fun loadView() {
view = UIView().apply {
backgroundColor = UIColor.clearColor
opaque = false
// backgroundColor = UIColor.whiteColor
setClipsToBounds(true)
} // rootView needs to interop with UIKit
}
override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
systemTheme.value = traitCollection.userInterfaceStyle.asComposeSystemTheme()
}
override fun viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// UIKit possesses all required info for layout at this point
currentInterfaceOrientation?.let {
interfaceOrientationState.value = it
}
val composeLayer = attachedComposeContext?.composeLayer ?: return
val (width, height) = getViewFrameSize()
val scale = density.density
composeLayer.setDensity(density)
composeLayer.setSize((width * scale).roundToInt(), (height * scale).roundToInt())
}
override fun viewWillAppear(animated: Boolean) {
super.viewWillAppear(animated)
attachComposeIfNeeded()
}
override fun viewDidAppear(animated: Boolean) {
super.viewDidAppear(animated)
NSNotificationCenter.defaultCenter.addObserver(
observer = keyboardVisibilityListener,
selector = NSSelectorFromString(keyboardVisibilityListener::keyboardWillShow.name + ":"),
name = UIKeyboardWillShowNotification,
`object` = null,
)
NSNotificationCenter.defaultCenter.addObserver(
observer = keyboardVisibilityListener,
selector = NSSelectorFromString(keyboardVisibilityListener::keyboardWillHide.name + ":"),
name = UIKeyboardWillHideNotification,
`object` = null,
)
}
// viewDidUnload() is deprecated and not called.
override fun viewWillDisappear(animated: Boolean) {
super.viewWillDisappear(animated)
NSNotificationCenter.defaultCenter.removeObserver(
observer = keyboardVisibilityListener,
name = UIKeyboardWillShowNotification,
`object` = null,
)
NSNotificationCenter.defaultCenter.removeObserver(
observer = keyboardVisibilityListener,
name = UIKeyboardWillHideNotification,
`object` = null,
)
}
@OptIn(NativeRuntimeApi::class)
override fun viewDidDisappear(animated: Boolean) {
super.viewDidDisappear(animated)
dispose()
dispatch_async(dispatch_get_main_queue()) {
GC.collect()
}
}
@OptIn(NativeRuntimeApi::class)
override fun didReceiveMemoryWarning() {
println("didReceiveMemoryWarning")
GC.collect()
super.didReceiveMemoryWarning()
}
fun setContent(
content: @Composable () -> Unit,
) {
this.content = content
}
fun dispose() {
attachedComposeContext?.dispose()
attachedComposeContext = null
}
private fun attachComposeIfNeeded() {
if (attachedComposeContext != null) {
return // already attached
}
val skiaLayer = createSkiaLayer()
val skikoUIView = SkikoUIView(
skiaLayer = skiaLayer,
pointInside = { point, _ ->
val composeLayer = attachedComposeContext?.composeLayer
if (composeLayer == null) {
false
} else {
!composeLayer.hitInteropView(point, isTouchEvent = true)
}
},
skikoUITextInputTrains = DelegateSkikoUITextInputTraits { attachedComposeContext?.inputTraits },
).load()
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()
},
hideSoftwareKeyboard = {
skikoUIView.hideScreenKeyboard()
},
updateView = {
skikoUIView.setNeedsDisplay() // redraw on next frame
platform.QuartzCore.CATransaction.flush() // clear all animations
skikoUIView.reloadInputViews() // update input (like screen keyboard)
},
textWillChange = { skikoUIView.textWillChange() },
textDidChange = { skikoUIView.textDidChange() },
selectionWillChange = { skikoUIView.selectionWillChange() },
selectionDidChange = { skikoUIView.selectionDidChange() },
)
val inputTraits = inputServices.skikoUITextInputTraits
val platform = object :
androidx.compose.ui.platform.Platform by androidx.compose.ui.platform.Platform.Empty {
override val windowInfo = WindowInfoImpl().apply {
isWindowFocused = true
}
override val focusManager = EmptyFocusManager
override val textInputService: PlatformTextInputService = inputServices
override val layoutDirection: LayoutDirection
get() = LayoutDirection.Ltr
override val viewConfiguration =
object : ViewConfiguration {
override val longPressTimeoutMillis: Long get() = 500
override val doubleTapTimeoutMillis: Long get() = 300
override val doubleTapMinTimeMillis: Long get() = 40
// this value is originating from iOS 16 drag behavior reverse-engenering
override val touchSlop: Float get() = with(density) { 10.dp.toPx() }
}
override val textToolbar = object : TextToolbar {
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
) {
val skiaRect = with(density) {
org.jetbrains.skia.Rect.makeLTRB(
l = rect.left / density,
t = rect.top / density,
r = rect.right / density,
b = rect.bottom / density,
)
}
skikoUIView.showTextMenu(
targetRect = skiaRect,
textActions = object : TextActions {
override val copy: (() -> Unit)? = onCopyRequested
override val cut: (() -> Unit)? = onCutRequested
override val paste: (() -> Unit)? = onPasteRequested
override val selectAll: (() -> Unit)? = onSelectAllRequested
},
)
}
/**
* TODO on UIKit native behaviour is hide text menu, when touch outside
*/
override fun hide() = skikoUIView.hideTextMenu()
override val status: TextToolbarStatus
get() = if (skikoUIView.isTextMenuShown()) {
TextToolbarStatus.Shown
} else {
TextToolbarStatus.Hidden
}
}
override val inputModeManager =
androidx.compose.ui.platform.DefaultInputModeManager(InputMode.Touch)
}
val composeLayer = TransparentComposeLayer(
layer = skiaLayer,
platform = platform,
input = inputServices.skikoInput,
)
composeLayer.setContent(
onPreviewKeyEvent = inputServices::onPreviewKeyEvent,
content = {
CompositionLocalProvider(
LocalLayerContainer provides view,
LocalUIViewController provides this,
LocalKeyboardOverlapHeightState provides keyboardOverlapHeightState,
LocalSafeAreaState provides safeAreaState,
LocalLayoutMarginsState provides layoutMarginsState,
LocalInterfaceOrientationState provides interfaceOrientationState,
LocalSystemTheme provides systemTheme.value,
) {
content()
}
},
)
attachedComposeContext =
AttachedComposeContext(composeLayer, skiaLayer, skikoUIView, inputTraits, platform)
}
private fun getViewFrameSize(): IntSize {
val (width, height) = view.frame().useContents { this.size.width to this.size.height }
return IntSize(width.toInt(), height.toInt())
}
}
private fun UIUserInterfaceStyle.asComposeSystemTheme(): SystemTheme {
return when (this) {
UIUserInterfaceStyle.UIUserInterfaceStyleLight -> SystemTheme.Light
UIUserInterfaceStyle.UIUserInterfaceStyleDark -> SystemTheme.Dark
else -> SystemTheme.Unknown
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment