diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSPlatformContextImpl.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSPlatformContextImpl.uikit.kt new file mode 100644 index 0000000000000..f6ca7b23cc4ce --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSPlatformContextImpl.uikit.kt @@ -0,0 +1,39 @@ +/* + * 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. + */ + +package androidx.compose.ui.platform + +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.text.input.PlatformTextInputService +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DensityProvider + +internal class IOSPlatformContextImpl( + inputServices: PlatformTextInputService, + override val textToolbar: TextToolbar, + override val windowInfo: WindowInfo, + densityProvider: DensityProvider, +) : PlatformContext by PlatformContext.Empty { + override val textInputService: PlatformTextInputService = inputServices + override val viewConfiguration = object : ViewConfiguration by EmptyViewConfiguration { + override val touchSlop: Float + get() = with(densityProvider()) { + // this value is originating from iOS 16 drag behavior reverse engineering + 10.dp.toPx() + } + } + override val inputModeManager = DefaultInputModeManager(InputMode.Touch) +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt similarity index 100% rename from compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.kt rename to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/SkikoUITextInputTraits.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/SkikoUITextInputTraits.kt deleted file mode 100644 index cf3d86c0273b0..0000000000000 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/SkikoUITextInputTraits.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.ui.platform - -import platform.UIKit.UIKeyboardAppearance -import platform.UIKit.UIKeyboardAppearanceDefault -import platform.UIKit.UIKeyboardType -import platform.UIKit.UIKeyboardTypeDefault -import platform.UIKit.UIReturnKeyType -import platform.UIKit.UITextAutocapitalizationType -import platform.UIKit.UITextAutocorrectionType -import platform.UIKit.UITextContentType -import platform.UIKit.UITextSmartDashesType -import platform.UIKit.UITextSmartInsertDeleteType -import platform.UIKit.UITextSmartQuotesType -import platform.UIKit.UITextSpellCheckingType - -internal interface SkikoUITextInputTraits { - fun keyboardType(): UIKeyboardType = - UIKeyboardTypeDefault - - fun keyboardAppearance(): UIKeyboardAppearance = - UIKeyboardAppearanceDefault - - fun returnKeyType(): UIReturnKeyType = - UIReturnKeyType.UIReturnKeyDefault - - fun textContentType(): UITextContentType? = - null - - fun isSecureTextEntry(): Boolean = - false - - fun enablesReturnKeyAutomatically(): Boolean = - false - - fun autocapitalizationType(): UITextAutocapitalizationType = - UITextAutocapitalizationType.UITextAutocapitalizationTypeSentences - - fun autocorrectionType(): UITextAutocorrectionType = - UITextAutocorrectionType.UITextAutocorrectionTypeYes - - fun spellCheckingType(): UITextSpellCheckingType = - UITextSpellCheckingType.UITextSpellCheckingTypeDefault - - fun smartQuotesType(): UITextSmartQuotesType = - UITextSmartQuotesType.UITextSmartQuotesTypeDefault - - fun smartDashesType(): UITextSmartDashesType = - UITextSmartDashesType.UITextSmartDashesTypeDefault - - fun smartInsertDeleteType(): UITextSmartInsertDeleteType = - UITextSmartInsertDeleteType.UITextSmartInsertDeleteTypeDefault - -} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/SkikoUITextInputTraits.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/SkikoUITextInputTraits.uikit.kt new file mode 100644 index 0000000000000..6d3b0e94b509e --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/SkikoUITextInputTraits.uikit.kt @@ -0,0 +1,156 @@ +/* + * 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. + */ + +package androidx.compose.ui.platform + +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import platform.UIKit.UIKeyboardAppearance +import platform.UIKit.UIKeyboardAppearanceDefault +import platform.UIKit.UIKeyboardType +import platform.UIKit.UIKeyboardTypeASCIICapable +import platform.UIKit.UIKeyboardTypeDecimalPad +import platform.UIKit.UIKeyboardTypeDefault +import platform.UIKit.UIKeyboardTypeEmailAddress +import platform.UIKit.UIKeyboardTypeNumberPad +import platform.UIKit.UIKeyboardTypePhonePad +import platform.UIKit.UIKeyboardTypeURL +import platform.UIKit.UIReturnKeyType +import platform.UIKit.UITextAutocapitalizationType +import platform.UIKit.UITextAutocorrectionType +import platform.UIKit.UITextContentType +import platform.UIKit.UITextContentTypeEmailAddress +import platform.UIKit.UITextContentTypePassword +import platform.UIKit.UITextContentTypeTelephoneNumber +import platform.UIKit.UITextSmartDashesType +import platform.UIKit.UITextSmartInsertDeleteType +import platform.UIKit.UITextSmartQuotesType +import platform.UIKit.UITextSpellCheckingType + +internal interface SkikoUITextInputTraits { + fun keyboardType(): UIKeyboardType = + UIKeyboardTypeDefault + + fun keyboardAppearance(): UIKeyboardAppearance = + UIKeyboardAppearanceDefault + + fun returnKeyType(): UIReturnKeyType = + UIReturnKeyType.UIReturnKeyDefault + + fun textContentType(): UITextContentType? = + null + + fun isSecureTextEntry(): Boolean = + false + + fun enablesReturnKeyAutomatically(): Boolean = + false + + fun autocapitalizationType(): UITextAutocapitalizationType = + UITextAutocapitalizationType.UITextAutocapitalizationTypeSentences + + fun autocorrectionType(): UITextAutocorrectionType = + UITextAutocorrectionType.UITextAutocorrectionTypeYes + + fun spellCheckingType(): UITextSpellCheckingType = + UITextSpellCheckingType.UITextSpellCheckingTypeDefault + + fun smartQuotesType(): UITextSmartQuotesType = + UITextSmartQuotesType.UITextSmartQuotesTypeDefault + + fun smartDashesType(): UITextSmartDashesType = + UITextSmartDashesType.UITextSmartDashesTypeDefault + + fun smartInsertDeleteType(): UITextSmartInsertDeleteType = + UITextSmartInsertDeleteType.UITextSmartInsertDeleteTypeDefault + +} + +internal fun getUITextInputTraits(currentImeOptions: ImeOptions?) = + object : SkikoUITextInputTraits { + override fun keyboardType(): UIKeyboardType = + when (currentImeOptions?.keyboardType) { + KeyboardType.Text -> UIKeyboardTypeDefault + KeyboardType.Ascii -> UIKeyboardTypeASCIICapable + KeyboardType.Number -> UIKeyboardTypeNumberPad + KeyboardType.Phone -> UIKeyboardTypePhonePad + KeyboardType.Uri -> UIKeyboardTypeURL + KeyboardType.Email -> UIKeyboardTypeEmailAddress + KeyboardType.Password -> UIKeyboardTypeASCIICapable // TODO Correct? + KeyboardType.NumberPassword -> UIKeyboardTypeNumberPad // TODO Correct? + KeyboardType.Decimal -> UIKeyboardTypeDecimalPad + else -> UIKeyboardTypeDefault + } + + override fun keyboardAppearance(): UIKeyboardAppearance = UIKeyboardAppearanceDefault + override fun returnKeyType(): UIReturnKeyType = + when (currentImeOptions?.imeAction) { + ImeAction.Default -> UIReturnKeyType.UIReturnKeyDefault + ImeAction.None -> UIReturnKeyType.UIReturnKeyDefault + ImeAction.Go -> UIReturnKeyType.UIReturnKeyGo + ImeAction.Search -> UIReturnKeyType.UIReturnKeySearch + ImeAction.Send -> UIReturnKeyType.UIReturnKeySend + ImeAction.Previous -> UIReturnKeyType.UIReturnKeyDefault + ImeAction.Next -> UIReturnKeyType.UIReturnKeyNext + ImeAction.Done -> UIReturnKeyType.UIReturnKeyDone + else -> UIReturnKeyType.UIReturnKeyDefault + } + + override fun textContentType(): UITextContentType? = + when (currentImeOptions?.keyboardType) { + KeyboardType.Password, KeyboardType.NumberPassword -> UITextContentTypePassword + KeyboardType.Email -> UITextContentTypeEmailAddress + KeyboardType.Phone -> UITextContentTypeTelephoneNumber + else -> null + } + + override fun isSecureTextEntry(): Boolean = + when (currentImeOptions?.keyboardType) { + KeyboardType.Password, KeyboardType.NumberPassword -> true + else -> false + } + + override fun enablesReturnKeyAutomatically(): Boolean = false + + override fun autocapitalizationType(): UITextAutocapitalizationType = + when (currentImeOptions?.capitalization) { + KeyboardCapitalization.None -> + UITextAutocapitalizationType.UITextAutocapitalizationTypeNone + + KeyboardCapitalization.Characters -> + UITextAutocapitalizationType.UITextAutocapitalizationTypeAllCharacters + + KeyboardCapitalization.Words -> + UITextAutocapitalizationType.UITextAutocapitalizationTypeWords + + KeyboardCapitalization.Sentences -> + UITextAutocapitalizationType.UITextAutocapitalizationTypeSentences + + else -> + UITextAutocapitalizationType.UITextAutocapitalizationTypeNone + } + + override fun autocorrectionType(): UITextAutocorrectionType = + when (currentImeOptions?.autoCorrect) { + true -> UITextAutocorrectionType.UITextAutocorrectionTypeYes + false -> UITextAutocorrectionType.UITextAutocorrectionTypeNo + else -> UITextAutocorrectionType.UITextAutocorrectionTypeDefault + } + + } + diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.uikit.kt similarity index 100% rename from compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.kt rename to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.uikit.kt diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt index 5ead6bb14bb20..1fd78651716d3 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt @@ -16,37 +16,34 @@ package androidx.compose.ui.platform +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.NativeKeyEvent import androidx.compose.ui.text.input.* +import androidx.compose.ui.window.DensityProvider +import androidx.compose.ui.window.FocusStack +import androidx.compose.ui.window.IntermediateTextInputUIView +import androidx.compose.ui.window.KeyboardEventHandler import kotlin.math.absoluteValue import kotlin.math.min -import kotlin.test.assertContains import org.jetbrains.skia.BreakIterator import org.jetbrains.skiko.SkikoKey import org.jetbrains.skiko.SkikoKeyboardEventKind import platform.UIKit.* internal class UIKitTextInputService( - showSoftwareKeyboard: () -> Unit, - hideSoftwareKeyboard: () -> Unit, private val updateView: () -> Unit, - private val textWillChange: () -> Unit, - private val textDidChange: () -> Unit, - private val selectionWillChange: () -> Unit, - private val selectionDidChange: () -> Unit, -) : PlatformTextInputService { - - data class CurrentInput( - var value: TextFieldValue, - val onEditCommand: (List) -> Unit - ) - - private val _showSoftwareKeyboard: () -> Unit = showSoftwareKeyboard - private val _hideSoftwareKeyboard: () -> Unit = hideSoftwareKeyboard + private val rootViewProvider: () -> UIView, + private val densityProvider: DensityProvider, + private val focusStack: FocusStack?, + private val keyboardEventHandler: KeyboardEventHandler, +) : PlatformTextInputService, TextToolbar { + + private val rootView get() = rootViewProvider() private var currentInput: CurrentInput? = null private var currentImeOptions: ImeOptions? = null private var currentImeActionHandler: ((ImeAction) -> Unit)? = null + private var textUIView: IntermediateTextInputUIView? = null /** * Workaround to prevent calling textWillChange, textDidChange, selectionWillChange, and @@ -107,6 +104,24 @@ internal class UIKitTextInputService( } currentImeOptions = imeOptions currentImeActionHandler = onImeActionPerformed + + textUIView = IntermediateTextInputUIView( + keyboardEventHandler = keyboardEventHandler, + ).also { + rootView.addSubview(it) + it.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activateConstraints( + listOf( + it.leftAnchor.constraintEqualToAnchor(rootView.leftAnchor), + it.rightAnchor.constraintEqualToAnchor(rootView.rightAnchor), + it.topAnchor.constraintEqualToAnchor(rootView.topAnchor), + it.bottomAnchor.constraintEqualToAnchor(rootView.bottomAnchor), + ) + ) + } + textUIView?.input = createSkikoInput(value) + textUIView?.inputTraits = getUITextInputTraits(imeOptions) + showSoftwareKeyboard() } @@ -116,25 +131,32 @@ internal class UIKitTextInputService( currentImeOptions = null currentImeActionHandler = null hideSoftwareKeyboard() + textUIView?.removeFromSuperview() + textUIView = null } override fun showSoftwareKeyboard() { - _showSoftwareKeyboard() + textUIView?.let { + focusStack?.pushAndFocus(it) + } } override fun hideSoftwareKeyboard() { - _hideSoftwareKeyboard() + textUIView?.let { + focusStack?.popUntilNext(it) + } } override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) { val internalOldValue = _tempCurrentInputSession?.toTextFieldValue() val textChanged = internalOldValue == null || internalOldValue.text != newValue.text - val selectionChanged = textChanged || internalOldValue == null || internalOldValue.selection != newValue.selection + val selectionChanged = + textChanged || internalOldValue == null || internalOldValue.selection != newValue.selection if (textChanged) { - textWillChange() + textUIView?.textWillChange() } if (selectionChanged) { - selectionWillChange() + textUIView?.selectionWillChange() } _tempCurrentInputSession?.reset(newValue, null) currentInput?.let { input -> @@ -142,18 +164,141 @@ internal class UIKitTextInputService( _tempCursorPos = null } if (textChanged) { - textDidChange() + textUIView?.textDidChange() } if (selectionChanged) { - selectionDidChange() + textUIView?.selectionDidChange() } if (textChanged || selectionChanged) { updateView() + textUIView?.reloadInputViews() + } + } + + fun onPreviewKeyEvent(event: KeyEvent): Boolean { + val nativeKeyEvent = event.nativeKeyEvent + return when (nativeKeyEvent.key) { + SkikoKey.KEY_ENTER -> handleEnterKey(nativeKeyEvent) + SkikoKey.KEY_BACKSPACE -> handleBackspace(nativeKeyEvent) + else -> false } } - val skikoInput = object : IOSSkikoInput { + private fun handleEnterKey(event: NativeKeyEvent): Boolean { + _tempImeActionIsCalledWithHardwareReturnKey = false + return when (event.kind) { + SkikoKeyboardEventKind.UP -> { + _tempHardwareReturnKeyPressed = false + false + } + + SkikoKeyboardEventKind.DOWN -> { + _tempHardwareReturnKeyPressed = true + // This prevents two new line characters from being added for one hardware return key press. + true + } + + else -> false + } + } + + private fun handleBackspace(event: NativeKeyEvent): Boolean { + // This prevents two characters from being removed for one hardware backspace key press. + return event.kind == SkikoKeyboardEventKind.DOWN + } + + private fun sendEditCommand(vararg commands: EditCommand) { + val commandList = commands.toList() + _tempCurrentInputSession?.apply(commandList) + currentInput?.let { input -> + input.onEditCommand(commandList) + } + } + + private fun getCursorPos(): Int? { + if (_tempCursorPos != null) { + return _tempCursorPos + } + val selection = getState()?.selection + if (selection != null && selection.start == selection.end) { + return selection.start + } + return null + } + + private fun imeActionRequired(): Boolean = + currentImeOptions?.run { + singleLine || ( + imeAction != ImeAction.None + && imeAction != ImeAction.Default + && !(imeAction == ImeAction.Search && _tempHardwareReturnKeyPressed) + ) + } ?: false + + private fun runImeActionIfRequired(): Boolean { + val imeAction = currentImeOptions?.imeAction ?: return false + val imeActionHandler = currentImeActionHandler ?: return false + if (!imeActionRequired()) { + return false + } + if (!_tempImeActionIsCalledWithHardwareReturnKey) { + if (imeAction == ImeAction.Default) { + imeActionHandler(ImeAction.Done) + } else { + imeActionHandler(imeAction) + } + } + if (_tempHardwareReturnKeyPressed) { + _tempImeActionIsCalledWithHardwareReturnKey = true + } + return true + } + + private fun getState(): TextFieldValue? = currentInput?.value + + private val density get() = densityProvider() + + 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, + ) + } + textUIView?.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() { + textUIView?.hideTextMenu() + } + + override val status: TextToolbarStatus + get() = if (textUIView?.isTextMenuShown() == true) + TextToolbarStatus.Shown + else + TextToolbarStatus.Hidden + + private fun createSkikoInput(value: TextFieldValue) = object : IOSSkikoInput { /** * A Boolean value that indicates whether the text-entry object has any text. * https://developer.apple.com/documentation/uikit/uikeyinput/1614457-hastext @@ -335,9 +480,9 @@ internal class UIKitTextInputService( * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614464-rangeenclosingposition?language=objc * @param position * A text-position object that represents a location in a document. - * @param granularity + * @param withGranularity * A constant that indicates a certain granularity of text unit. - * @param direction + * @param inDirection * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. * @return * A text-range representing a text unit of the given granularity in the given direction, or nil if there is no such enclosing unit. @@ -370,7 +515,9 @@ internal class UIKitTextInputService( var current: Int = position fun currentRange() = IntRange(current, position) - fun nextAddition() = IntRange(iterator.preceding(current).coerceAtLeast(0), current) + fun nextAddition() = + IntRange(iterator.preceding(current).coerceAtLeast(0), current) + fun IntRange.text() = text.substring(start, endInclusive) while ( @@ -396,9 +543,9 @@ internal class UIKitTextInputService( * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614553-isposition?language=objc * @param position * A text-position object that represents a location in a document. - * @param granularity + * @param atBoundary * A constant that indicates a certain granularity of text unit. - * @param direction + * @param inDirection * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. * @return * TRUE if the text position is at the given text-unit boundary in the given direction; FALSE if it is not at the boundary. @@ -421,9 +568,9 @@ internal class UIKitTextInputService( * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614491-isposition?language=objc * @param position * A text-position object that represents a location in a document. - * @param granularity + * @param withinTextUnit * A constant that indicates a certain granularity of text unit. - * @param direction + * @param inDirection * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. * @return * TRUE if the text position is within a text unit of the specified granularity in the specified direction; otherwise, return FALSE. @@ -449,161 +596,6 @@ internal class UIKitTextInputService( } } - val skikoUITextInputTraits = object : SkikoUITextInputTraits { - override fun keyboardType(): UIKeyboardType = - when (currentImeOptions?.keyboardType) { - KeyboardType.Text -> UIKeyboardTypeDefault - KeyboardType.Ascii -> UIKeyboardTypeASCIICapable - KeyboardType.Number -> UIKeyboardTypeNumberPad - KeyboardType.Phone -> UIKeyboardTypePhonePad - KeyboardType.Uri -> UIKeyboardTypeURL - KeyboardType.Email -> UIKeyboardTypeEmailAddress - KeyboardType.Password -> UIKeyboardTypeASCIICapable // TODO Correct? - KeyboardType.NumberPassword -> UIKeyboardTypeNumberPad // TODO Correct? - KeyboardType.Decimal -> UIKeyboardTypeDecimalPad - else -> UIKeyboardTypeDefault - } - - override fun keyboardAppearance(): UIKeyboardAppearance = UIKeyboardAppearanceDefault - override fun returnKeyType(): UIReturnKeyType = - when (currentImeOptions?.imeAction) { - ImeAction.Default -> UIReturnKeyType.UIReturnKeyDefault - ImeAction.None -> UIReturnKeyType.UIReturnKeyDefault - ImeAction.Go -> UIReturnKeyType.UIReturnKeyGo - ImeAction.Search -> UIReturnKeyType.UIReturnKeySearch - ImeAction.Send -> UIReturnKeyType.UIReturnKeySend - ImeAction.Previous -> UIReturnKeyType.UIReturnKeyDefault - ImeAction.Next -> UIReturnKeyType.UIReturnKeyNext - ImeAction.Done -> UIReturnKeyType.UIReturnKeyDone - else -> UIReturnKeyType.UIReturnKeyDefault - } - - override fun textContentType(): UITextContentType? = null -// TODO: Prevent Issue https://youtrack.jetbrains.com/issue/COMPOSE-319/iOS-Bug-password-TextField-changes-behavior-for-all-other-TextFieds -// when (currentImeOptions?.keyboardType) { -// KeyboardType.Password, KeyboardType.NumberPassword -> UITextContentTypePassword -// KeyboardType.Email -> UITextContentTypeEmailAddress -// KeyboardType.Phone -> UITextContentTypeTelephoneNumber -// else -> null -// } - - override fun isSecureTextEntry(): Boolean = false -// TODO: Prevent Issue https://youtrack.jetbrains.com/issue/COMPOSE-319/iOS-Bug-password-TextField-changes-behavior-for-all-other-TextFieds -// when (currentImeOptions?.keyboardType) { -// KeyboardType.Password, KeyboardType.NumberPassword -> true -// else -> false -// } - - override fun enablesReturnKeyAutomatically(): Boolean = false - - override fun autocapitalizationType(): UITextAutocapitalizationType = - when (currentImeOptions?.capitalization) { - KeyboardCapitalization.None -> - UITextAutocapitalizationType.UITextAutocapitalizationTypeNone - - KeyboardCapitalization.Characters -> - UITextAutocapitalizationType.UITextAutocapitalizationTypeAllCharacters - - KeyboardCapitalization.Words -> - UITextAutocapitalizationType.UITextAutocapitalizationTypeWords - - KeyboardCapitalization.Sentences -> - UITextAutocapitalizationType.UITextAutocapitalizationTypeSentences - - else -> - UITextAutocapitalizationType.UITextAutocapitalizationTypeNone - } - - override fun autocorrectionType(): UITextAutocorrectionType = - when (currentImeOptions?.autoCorrect) { - true -> UITextAutocorrectionType.UITextAutocorrectionTypeYes - false -> UITextAutocorrectionType.UITextAutocorrectionTypeNo - else -> UITextAutocorrectionType.UITextAutocorrectionTypeDefault - } - - } - - fun onPreviewKeyEvent(event: KeyEvent): Boolean { - val nativeKeyEvent = event.nativeKeyEvent - return when (nativeKeyEvent.key) { - SkikoKey.KEY_ENTER -> handleEnterKey(nativeKeyEvent) - SkikoKey.KEY_BACKSPACE -> handleBackspace(nativeKeyEvent) - else -> false - } - } - - private fun handleEnterKey(event: NativeKeyEvent): Boolean { - _tempImeActionIsCalledWithHardwareReturnKey = false - return when (event.kind) { - SkikoKeyboardEventKind.UP -> { - _tempHardwareReturnKeyPressed = false - false - } - - SkikoKeyboardEventKind.DOWN -> { - _tempHardwareReturnKeyPressed = true - // This prevents two new line characters from being added for one hardware return key press. - true - } - - else -> false - } - } - - private fun handleBackspace(event: NativeKeyEvent): Boolean { - // This prevents two characters from being removed for one hardware backspace key press. - return event.kind == SkikoKeyboardEventKind.DOWN - } - - private fun sendEditCommand(vararg commands: EditCommand) { - val commandList = commands.toList() - _tempCurrentInputSession?.apply(commandList) - currentInput?.let { input -> - input.onEditCommand(commandList) - } - } - - private fun getCursorPos(): Int? { - if (_tempCursorPos != null) { - return _tempCursorPos - } - val selection = getState()?.selection - if (selection != null && selection.start == selection.end) { - return selection.start - } - return null - } - - private fun imeActionRequired(): Boolean = - currentImeOptions?.run { - singleLine || ( - imeAction != ImeAction.None - && imeAction != ImeAction.Default - && !(imeAction == ImeAction.Search && _tempHardwareReturnKeyPressed) - ) - } ?: false - - private fun runImeActionIfRequired(): Boolean { - val imeAction = currentImeOptions?.imeAction ?: return false - val imeActionHandler = currentImeActionHandler ?: return false - if (!imeActionRequired()) { - return false - } - if (!_tempImeActionIsCalledWithHardwareReturnKey) { - if (imeAction == ImeAction.Default) { - imeActionHandler(ImeAction.Done) - } else { - imeActionHandler(imeAction) - } - } - if (_tempHardwareReturnKeyPressed) { - _tempImeActionIsCalledWithHardwareReturnKey = true - } - return true - } - - private fun getState(): TextFieldValue? = currentInput?.value - } private fun UITextGranularity.toTextIterator() = @@ -616,3 +608,8 @@ private fun UITextGranularity.toTextIterator() = UITextGranularity.UITextGranularityDocument -> TODO("UITextGranularityDocument iterator") else -> error("Unknown granularity") } + +private data class CurrentInput( + var value: TextFieldValue, + val onEditCommand: (List) -> Unit +) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.uikit.kt similarity index 94% rename from compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.kt rename to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.uikit.kt index ff922501a66b3..c3829be608965 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.uikit.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.uikit +import androidx.compose.runtime.ExperimentalComposeApi + /** * Configuration of ComposeUIViewController behavior. */ @@ -30,6 +32,9 @@ class ComposeUIViewControllerConfiguration { * UIViewController lifetime events. */ var delegate = object : ComposeUIViewControllerDelegate {} + + @ExperimentalComposeApi + var platformLayers: Boolean = false } /** diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/PlistSanityCheck.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/PlistSanityCheck.uikit.kt similarity index 100% rename from compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/PlistSanityCheck.kt rename to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/PlistSanityCheck.uikit.kt diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt new file mode 100644 index 0000000000000..993bac2f4fcc7 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt @@ -0,0 +1,397 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.LocalSystemTheme +import androidx.compose.ui.SystemTheme +import androidx.compose.ui.interop.LocalLayerContainer +import androidx.compose.ui.interop.LocalUIViewController +import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.platform.WindowInfoImpl +import androidx.compose.ui.scene.ComposeSceneContext +import androidx.compose.ui.scene.ComposeSceneLayer +import androidx.compose.ui.scene.MultiLayerComposeScene +import androidx.compose.ui.scene.SingleLayerComposeScene +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration +import androidx.compose.ui.uikit.InterfaceOrientation +import androidx.compose.ui.uikit.LocalInterfaceOrientation +import androidx.compose.ui.uikit.PlistSanityCheck +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.util.fastForEach +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 kotlinx.coroutines.Dispatchers +import org.jetbrains.skiko.OS +import org.jetbrains.skiko.OSVersion +import org.jetbrains.skiko.available +import platform.CoreGraphics.CGRect +import platform.CoreGraphics.CGSize +import platform.CoreGraphics.CGSizeEqualToSize +import platform.Foundation.NSStringFromClass +import platform.UIKit.UIApplication +import platform.UIKit.UIColor +import platform.UIKit.UITraitCollection +import platform.UIKit.UIUserInterfaceLayoutDirection +import platform.UIKit.UIUserInterfaceStyle +import platform.UIKit.UIView +import platform.UIKit.UIViewController +import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue + +private val coroutineDispatcher = Dispatchers.Main + +@OptIn(InternalComposeApi::class) +@ExportObjCClass +internal class ComposeContainer( + private val configuration: ComposeUIViewControllerConfiguration, + private val content: @Composable () -> Unit, +) : UIViewController(nibName = null, bundle = null) { + + private var isInsideSwiftUI = false + private var composeSceneMediator: ComposeSceneMediator? = null + private val layers: MutableList = mutableListOf() + private val layoutDirection get() = getLayoutDirection() + + /* + * Initial value is arbitrarily chosen to avoid propagating invalid value logic + * It's never the case in real usage scenario to reflect that in type system + */ + val interfaceOrientationState: MutableState = mutableStateOf( + InterfaceOrientation.Portrait + ) + val systemThemeState: MutableState = mutableStateOf(SystemTheme.Unknown) + private val focusStack: FocusStack = FocusStackImpl() + private val windowInfo = WindowInfoImpl().also { + it.isWindowFocused = true + } + + /* + * On iOS >= 13.0 interfaceOrientation will be deduced from [UIWindowScene] of [UIWindow] + * to which our [RootUIViewController] 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() { + // Modern: https://developer.apple.com/documentation/uikit/uiwindowscene/3198088-interfaceorientation?language=objc + // Deprecated: https://developer.apple.com/documentation/uikit/uiapplication/1623026-statusbarorientation?language=objc + return if (available(OS.Ios to OSVersion(13))) { + view.window?.windowScene?.interfaceOrientation?.let { + InterfaceOrientation.getByRawValue(it) + } + } else { + InterfaceOrientation.getByRawValue(UIApplication.sharedApplication.statusBarOrientation) + } + } + + @Suppress("unused") + @ObjCAction + fun viewSafeAreaInsetsDidChange() { + // super.viewSafeAreaInsetsDidChange() // TODO: call super after Kotlin 1.8.20 + composeSceneMediator?.viewSafeAreaInsetsDidChange() + layers.fastForEach { + it.viewSafeAreaInsetsDidChange() + } + } + + override fun loadView() { + view = UIView().apply { + backgroundColor = UIColor.whiteColor + setClipsToBounds(true) + } // rootView needs to interop with UIKit + } + + override fun viewDidLoad() { + super.viewDidLoad() + PlistSanityCheck.performIfNeeded() + configuration.delegate.viewDidLoad() + systemThemeState.value = traitCollection.userInterfaceStyle.asComposeSystemTheme() + } + + override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + systemThemeState.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 window = checkNotNull(view.window) { + "ComposeUIViewController.view should be attached to window" + } + val scale = window.screen.scale + val size = window.frame.useContents { + IntSize( + width = (size.width * scale).roundToInt(), + height = (size.height * scale).roundToInt() + ) + } + windowInfo.containerSize = size + composeSceneMediator?.viewWillLayoutSubviews() + layers.fastForEach { + it.viewWillLayoutSubviews() + } + } + + override fun viewWillTransitionToSize( + size: CValue, + 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 + } + + // Happens during orientation change from LandscapeLeft to LandscapeRight, for example + val isSameSizeTransition = view.frame.useContents { + CGSizeEqualToSize(size, this.size.readValue()) + } + if (isSameSizeTransition) { + return + } + + composeSceneMediator?.viewWillTransitionToSize( + targetSize = size, + coordinator = withTransitionCoordinator, + ) + layers.fastForEach { + it.viewWillTransitionToSize( + targetSize = size, + coordinator = withTransitionCoordinator, + ) + } + view.layoutIfNeeded() + } + + override fun viewWillAppear(animated: Boolean) { + super.viewWillAppear(animated) + + isInsideSwiftUI = checkIfInsideSwiftUI() + setContent(content) + configuration.delegate.viewWillAppear(animated) + } + + override fun viewDidAppear(animated: Boolean) { + super.viewDidAppear(animated) + composeSceneMediator?.viewDidAppear(animated) + layers.fastForEach { + it.viewDidAppear(animated) + } + configuration.delegate.viewDidAppear(animated) + } + + // viewDidUnload() is deprecated and not called. + override fun viewWillDisappear(animated: Boolean) { + super.viewWillDisappear(animated) + composeSceneMediator?.viewWillDisappear(animated) + layers.fastForEach { + it.viewWillDisappear(animated) + } + configuration.delegate.viewWillDisappear(animated) + } + + override fun viewDidDisappear(animated: Boolean) { + super.viewDidDisappear(animated) + + dispose() + + dispatch_async(dispatch_get_main_queue()) { + kotlin.native.internal.GC.collect() + } + + configuration.delegate.viewDidDisappear(animated) + } + + override fun didReceiveMemoryWarning() { + println("didReceiveMemoryWarning") + kotlin.native.internal.GC.collect() + super.didReceiveMemoryWarning() + } + + @OptIn(ExperimentalComposeApi::class) + private fun setContent(content: @Composable () -> Unit) { + if (composeSceneMediator != null) { + return // already attached + } + + val mediator = if (configuration.platformLayers) { + createSingleLayerComposeSceneMediator() + } else { + createMultiLayerComposeSceneMediator() + } + composeSceneMediator = mediator + mediator.setContent { + ProvideContainerCompositionLocals(this) { + content() + } + } + mediator.setLayout(SceneLayout.UseConstraintsToFillContainer) + } + + private fun dispose() { + composeSceneMediator?.dispose() + composeSceneMediator = null + layers.fastForEach { + it.close() + } + + } + + private fun createSingleLayerComposeSceneMediator(): ComposeSceneMediator = + ComposeSceneMediator( + viewController = this, + configuration = configuration, + focusStack = focusStack, + windowInfo = windowInfo, + transparency = false + ) { mediator: ComposeSceneMediator -> + SingleLayerComposeScene( + coroutineContext = coroutineDispatcher, + density = mediator.densityProvider(), + invalidate = mediator::onComposeSceneInvalidate, + layoutDirection = layoutDirection, + composeSceneContext = ComposeSceneContextImpl( + platformContext = mediator.platformContext + ), + ) + } + + private fun createMultiLayerComposeSceneMediator(): ComposeSceneMediator = + ComposeSceneMediator( + viewController = this, + configuration = configuration, + focusStack = focusStack, + windowInfo = windowInfo, + transparency = false, + ) { mediator -> + MultiLayerComposeScene( + coroutineContext = coroutineDispatcher, + composeSceneContext = ComposeSceneContextImpl( + platformContext = mediator.platformContext + ), + density = mediator.densityProvider(), + invalidate = mediator::onComposeSceneInvalidate, + layoutDirection = layoutDirection, + ) + } + + fun attachLayer(layer: UIViewComposeSceneLayer) { + layers.add(layer) + } + + fun detachLayer(layer: UIViewComposeSceneLayer) { + layers.remove(layer) + } + + private inner class ComposeSceneContextImpl( + override val platformContext: PlatformContext, + ) : ComposeSceneContext { + override fun createPlatformLayer( + density: Density, + layoutDirection: LayoutDirection, + focusable: Boolean, + compositionContext: CompositionContext + ): ComposeSceneLayer = + UIViewComposeSceneLayer( + composeContainer = this@ComposeContainer, + density = density, + layoutDirection = layoutDirection, + configuration = configuration, + focusStack = if (focusable) focusStack else null, + windowInfo = windowInfo, + compositionContext = compositionContext, + compositionLocalContext = composeSceneMediator?.compositionLocalContext, + composeSceneContext = this, + ) + } + +} + +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 + UIUserInterfaceStyle.UIUserInterfaceStyleDark -> SystemTheme.Dark + else -> SystemTheme.Unknown + } +} + +private fun getLayoutDirection() = + when (UIApplication.sharedApplication().userInterfaceLayoutDirection) { + UIUserInterfaceLayoutDirection.UIUserInterfaceLayoutDirectionRightToLeft -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } + +@OptIn(InternalComposeApi::class) +@Composable +internal fun ProvideContainerCompositionLocals( + composeContainer: ComposeContainer, + content: @Composable () -> Unit, +) = with(composeContainer) { + CompositionLocalProvider( + LocalUIViewController provides this, + LocalLayerContainer provides view, + LocalInterfaceOrientation provides interfaceOrientationState.value, + LocalSystemTheme provides systemThemeState.value, + content = content + ) +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeSceneMediator.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeSceneMediator.uikit.kt new file mode 100644 index 0000000000000..4bf8c87d2c63e --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeSceneMediator.uikit.kt @@ -0,0 +1,439 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.interop.LocalUIKitInteropContext +import androidx.compose.ui.interop.UIKitInteropContext +import androidx.compose.ui.platform.IOSPlatformContextImpl +import androidx.compose.ui.platform.LocalLayoutMargins +import androidx.compose.ui.platform.LocalSafeArea +import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.platform.PlatformInsets +import androidx.compose.ui.platform.UIKitTextInputService +import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.scene.ComposeScene +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration +import androidx.compose.ui.uikit.LocalKeyboardOverlapHeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toOffset +import kotlin.math.roundToInt +import kotlinx.cinterop.CValue +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.readValue +import kotlinx.cinterop.useContents +import org.jetbrains.skiko.SkikoKeyboardEvent +import platform.CoreGraphics.CGAffineTransformIdentity +import platform.CoreGraphics.CGAffineTransformInvert +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGRectZero +import platform.CoreGraphics.CGSize +import platform.Foundation.NSNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSSelectorFromString +import platform.QuartzCore.CATransaction +import platform.UIKit.NSLayoutConstraint +import platform.UIKit.UIKeyboardWillHideNotification +import platform.UIKit.UIKeyboardWillShowNotification +import platform.UIKit.UIView +import platform.UIKit.UIViewController +import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol +import platform.darwin.NSObject + +/** + * Layout of sceneView on the screen + */ +internal sealed interface SceneLayout { + object Undefined : SceneLayout + object UseConstraintsToFillContainer : SceneLayout + class UseConstraintsToCenter(val size: CValue) : SceneLayout + class Bounds(val rect: IntRect) : SceneLayout +} + +internal class ComposeSceneMediator( + private val viewController: UIViewController, + configuration: ComposeUIViewControllerConfiguration, + private val focusStack: FocusStack?, + private val windowInfo: WindowInfo, + transparency: Boolean, + buildScene: (ComposeSceneMediator) -> ComposeScene, +) { + private val focusable: Boolean get() = focusStack != null + private val keyboardOverlapHeightState: MutableState = mutableStateOf(0f) + private var _layout: SceneLayout = SceneLayout.Undefined + private var constraints: List = emptyList() + set(value) { + if (field.isNotEmpty()) { + NSLayoutConstraint.deactivateConstraints(field) + } + field = value + NSLayoutConstraint.activateConstraints(value) + } + + private val scene: ComposeScene by lazy { buildScene(this) } + var compositionLocalContext + get() = scene.compositionLocalContext + set(value) { + scene.compositionLocalContext = value + } + private val focusManager get() = scene.focusManager + + val view: SkikoUIView by lazy { + SkikoUIView(keyboardEventHandler, delegate, transparency) + } + + val densityProvider by lazy { + DensityProviderImpl( + uiViewControllerProvider = { viewController }, + viewProvider = { view }, + ) + } + + private val interopContext: UIKitInteropContext by lazy { + UIKitInteropContext( + requestRedraw = { onComposeSceneInvalidate() } + ) + } + + val platformContext: PlatformContext by lazy { + IOSPlatformContextImpl( + inputServices = uiKitTextInputService, + textToolbar = uiKitTextInputService, + windowInfo = windowInfo, + densityProvider = densityProvider, + ) + } + + private val keyboardVisibilityListener by lazy { + KeyboardVisibilityListenerImpl( + configuration = configuration, + keyboardOverlapHeightState = keyboardOverlapHeightState, + viewProvider = { viewController.view }, + densityProvider = densityProvider, + composeSceneMediatorProvider = { this }, + focusManager = focusManager, + ) + } + + private val keyboardEventHandler: KeyboardEventHandler by lazy { + object : KeyboardEventHandler { + override fun onKeyboardEvent(event: SkikoKeyboardEvent) { + val composeEvent = KeyEvent(event) + if (!uiKitTextInputService.onPreviewKeyEvent(composeEvent)) { + scene.sendKeyEvent(composeEvent) + } + } + } + } + + private val uiKitTextInputService: UIKitTextInputService by lazy { + UIKitTextInputService( + updateView = { + view.setNeedsDisplay() // redraw on next frame + CATransaction.flush() // clear all animations + }, + rootViewProvider = { viewController.view }, + densityProvider = densityProvider, + focusStack = focusStack, + keyboardEventHandler = keyboardEventHandler + ) + } + + private val delegate: SkikoUIViewDelegate by lazy { + SkikoUIViewDelegateImpl( + { scene }, + interopContext, + densityProvider, + ) + } + + private var onAttachedToWindow: (() -> Unit)? = null + private fun runOnceViewAttached(block: () -> Unit) { + if (view.window == null) { + onAttachedToWindow = { + onAttachedToWindow = null + block() + } + } else { + block() + } + } + + init { + view.onAttachedToWindow = { + view.onAttachedToWindow = null + viewWillLayoutSubviews() + this.onAttachedToWindow?.invoke() + focusStack?.pushAndFocus(view) + } + viewController.view.addSubview(view) + } + + fun setContent(content: @Composable () -> Unit) { + runOnceViewAttached { + scene.setContent { + /** + * TODO isReadyToShowContent it is workaround we need to fix. + * https://github.com/JetBrains/compose-multiplatform-core/pull/861 + * Density problem already was fixed. + * But there are still problem with safeArea. + * Elijah founded possible solution: + * https://developer.apple.com/documentation/uikit/uiviewcontroller/4195485-viewisappearing + * It is public for iOS 17 and hope back ported for iOS 13 as well (but we need to check) + */ + if (view.isReadyToShowContent.value) { + ProvideComposeSceneMediatorCompositionLocals { + content() + } + } + } + } + } + + private val safeAreaState: MutableState by lazy { + //TODO It calc 0,0,0,0 on initialization + mutableStateOf(calcSafeArea()) + } + private val layoutMarginsState: MutableState by lazy { + //TODO It calc 0,0,0,0 on initialization + mutableStateOf(calcLayoutMargin()) + } + + fun viewSafeAreaInsetsDidChange() { + safeAreaState.value = calcSafeArea() + layoutMarginsState.value = calcLayoutMargin() + } + + @OptIn(InternalComposeApi::class) + @Composable + private fun ProvideComposeSceneMediatorCompositionLocals(content: @Composable () -> Unit) = + CompositionLocalProvider( + LocalUIKitInteropContext provides interopContext, + LocalKeyboardOverlapHeight provides keyboardOverlapHeightState.value, + LocalSafeArea provides safeAreaState.value, + LocalLayoutMargins provides layoutMarginsState.value, + content = content + ) + + fun dispose() { + focusStack?.popUntilNext(view) + view.dispose() + view.removeFromSuperview() + scene.close() + // After scene is disposed all UIKit interop actions can't be deferred to be synchronized with rendering + // Thus they need to be executed now. + interopContext.retrieve().actions.forEach { it.invoke() } + } + + fun onComposeSceneInvalidate() = view.needRedraw() + + fun setLayout(value: SceneLayout) { + _layout = value + when (value) { + SceneLayout.UseConstraintsToFillContainer -> { + delegate.metalOffset = Offset.Zero + view.setFrame(CGRectZero.readValue()) + view.translatesAutoresizingMaskIntoConstraints = false + constraints = listOf( + view.leftAnchor.constraintEqualToAnchor(viewController.view.leftAnchor), + view.rightAnchor.constraintEqualToAnchor(viewController.view.rightAnchor), + view.topAnchor.constraintEqualToAnchor(viewController.view.topAnchor), + view.bottomAnchor.constraintEqualToAnchor(viewController.view.bottomAnchor) + ) + } + + is SceneLayout.UseConstraintsToCenter -> { + delegate.metalOffset = Offset.Zero + view.setFrame(CGRectZero.readValue()) + view.translatesAutoresizingMaskIntoConstraints = false + constraints = value.size.useContents { + listOf( + view.centerXAnchor.constraintEqualToAnchor(viewController.view.centerXAnchor), + view.centerYAnchor.constraintEqualToAnchor(viewController.view.centerYAnchor), + view.widthAnchor.constraintEqualToConstant(width), + view.heightAnchor.constraintEqualToConstant(height) + ) + } + } + + is SceneLayout.Bounds -> { + delegate.metalOffset = -value.rect.topLeft.toOffset() + val density = densityProvider().density + view.translatesAutoresizingMaskIntoConstraints = true + view.setFrame( + with(value.rect) { + CGRectMake( + x = left.toDouble() / density, + y = top.toDouble() / density, + width = width.toDouble() / density, + height = height.toDouble() / density + ) + } + ) + constraints = emptyList() + } + + is SceneLayout.Undefined -> error("setLayout, SceneLayout.Undefined") + } + view.updateMetalLayerSize() + } + + fun viewWillLayoutSubviews() { + val density = densityProvider() + val scale = density.density + //TODO: Current code updates layout based on rootViewController size. + // Maybe we need to rewrite it for SingleLayerComposeScene. + val size = viewController.view.frame.useContents { + IntSize( + width = (size.width * scale).roundToInt(), + height = (size.height * scale).roundToInt() + ) + } + scene.density = density + scene.size = size + onComposeSceneInvalidate() + } + + private fun calcSafeArea(): PlatformInsets = + viewController.view.safeAreaInsets.useContents { + PlatformInsets( + left = left.dp, + top = top.dp, + right = right.dp, + bottom = bottom.dp, + ) + } + + private fun calcLayoutMargin(): PlatformInsets = + viewController.view.directionalLayoutMargins.useContents { + PlatformInsets( + left = leading.dp, // TODO: Check RTL support + top = top.dp, + right = trailing.dp, // TODO: Check RTL support + bottom = bottom.dp, + ) + } + + fun getViewBounds(): IntRect = view.frame.useContents { + val density = densityProvider().density + IntRect( + offset = IntOffset( + x = (origin.x * density).roundToInt(), + y = (origin.y * density).roundToInt(), + ), + size = IntSize( + width = (size.width * density).roundToInt(), + height = (size.height * density).roundToInt(), + ) + ) + } + + fun viewWillTransitionToSize( + targetSize: CValue, + coordinator: UIViewControllerTransitionCoordinatorProtocol + ) { + if (_layout is SceneLayout.Bounds) { + //TODO Add logic to SceneLayout.Bounds too + return + } + + val startSnapshotView = view.snapshotViewAfterScreenUpdates(false) ?: return + startSnapshotView.translatesAutoresizingMaskIntoConstraints = false + viewController.view.addSubview(startSnapshotView) + targetSize.useContents { + NSLayoutConstraint.activateConstraints( + listOf( + startSnapshotView.widthAnchor.constraintEqualToConstant(height), + startSnapshotView.heightAnchor.constraintEqualToConstant(width), + startSnapshotView.centerXAnchor.constraintEqualToAnchor(viewController.view.centerXAnchor), + startSnapshotView.centerYAnchor.constraintEqualToAnchor(viewController.view.centerYAnchor) + ) + ) + } + + view.isForcedToPresentWithTransactionEveryFrame = true + + setLayout(SceneLayout.UseConstraintsToCenter(size = targetSize)) + view.transform = coordinator.targetTransform + + coordinator.animateAlongsideTransition( + animation = { + startSnapshotView.alpha = 0.0 + startSnapshotView.transform = CGAffineTransformInvert(coordinator.targetTransform) + view.transform = CGAffineTransformIdentity.readValue() + }, + completion = { + startSnapshotView.removeFromSuperview() + setLayout(SceneLayout.UseConstraintsToFillContainer) + view.isForcedToPresentWithTransactionEveryFrame = false + } + ) + } + + private val nativeKeyboardVisibilityListener = object : NSObject() { + @Suppress("unused") + @ObjCAction + fun keyboardWillShow(arg: NSNotification) { + keyboardVisibilityListener.keyboardWillShow(arg) + } + + @Suppress("unused") + @ObjCAction + fun keyboardWillHide(arg: NSNotification) { + keyboardVisibilityListener.keyboardWillHide(arg) + } + } + + fun viewDidAppear(animated: Boolean) { + NSNotificationCenter.defaultCenter.addObserver( + observer = nativeKeyboardVisibilityListener, + selector = NSSelectorFromString(nativeKeyboardVisibilityListener::keyboardWillShow.name + ":"), + name = UIKeyboardWillShowNotification, + `object` = null + ) + NSNotificationCenter.defaultCenter.addObserver( + observer = nativeKeyboardVisibilityListener, + selector = NSSelectorFromString(nativeKeyboardVisibilityListener::keyboardWillHide.name + ":"), + name = UIKeyboardWillHideNotification, + `object` = null + ) + } + + // viewDidUnload() is deprecated and not called. + fun viewWillDisappear(animated: Boolean) { + NSNotificationCenter.defaultCenter.removeObserver( + observer = nativeKeyboardVisibilityListener, + name = UIKeyboardWillShowNotification, + `object` = null + ) + NSNotificationCenter.defaultCenter.removeObserver( + observer = nativeKeyboardVisibilityListener, + name = UIKeyboardWillHideNotification, + `object` = null + ) + } + +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeUIViewController.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeUIViewController.uikit.kt new file mode 100644 index 0000000000000..2c562acc99278 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeUIViewController.uikit.kt @@ -0,0 +1,32 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.runtime.Composable +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration +import platform.UIKit.UIViewController + +fun ComposeUIViewController(content: @Composable () -> Unit): UIViewController = + ComposeUIViewController(configure = {}, content = content) + +fun ComposeUIViewController( + configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, + content: @Composable () -> Unit +): UIViewController = ComposeContainer( + configuration = ComposeUIViewControllerConfiguration().apply(configure), + content = content, +) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt deleted file mode 100644 index acde7248cc199..0000000000000 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt +++ /dev/null @@ -1,829 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.ui.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.InternalComposeApi -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.LocalSystemTheme -import androidx.compose.ui.SystemTheme -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.asComposeCanvas -import androidx.compose.ui.input.InputMode -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.pointer.HistoricalChange -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.PointerId -import androidx.compose.ui.input.pointer.PointerType -import androidx.compose.ui.interop.LocalLayerContainer -import androidx.compose.ui.interop.LocalUIKitInteropContext -import androidx.compose.ui.interop.LocalUIViewController -import androidx.compose.ui.interop.UIKitInteropContext -import androidx.compose.ui.interop.UIKitInteropTransaction -import androidx.compose.ui.platform.* -import androidx.compose.ui.scene.MultiLayerComposeScene -import androidx.compose.ui.scene.ComposeScene -import androidx.compose.ui.scene.ComposeSceneContext -import androidx.compose.ui.scene.ComposeScenePointer -import androidx.compose.ui.text.input.PlatformTextInputService -import androidx.compose.ui.uikit.* -import androidx.compose.ui.unit.* -import kotlin.math.floor -import kotlin.math.roundToInt -import kotlin.math.roundToLong -import kotlinx.cinterop.CValue -import kotlinx.cinterop.ExportObjCClass -import kotlinx.cinterop.ObjCAction -import kotlinx.cinterop.readValue -import kotlinx.cinterop.useContents -import kotlinx.coroutines.Dispatchers -import org.jetbrains.skia.Canvas -import org.jetbrains.skiko.OS -import org.jetbrains.skiko.OSVersion -import org.jetbrains.skiko.SkikoKeyboardEvent -import org.jetbrains.skiko.available -import platform.CoreGraphics.CGAffineTransformIdentity -import platform.CoreGraphics.CGAffineTransformInvert -import platform.CoreGraphics.CGFloat -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.QuartzCore.CADisplayLink -import platform.QuartzCore.CATransaction -import platform.QuartzCore.kCATransactionDisableActions -import platform.UIKit.* -import platform.darwin.NSObject -import platform.darwin.dispatch_async -import platform.darwin.dispatch_get_main_queue -import platform.darwin.sel_registerName - -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 ComposeUIViewController(content: @Composable () -> Unit): UIViewController = - ComposeUIViewController(configure = {}, content = content) - -fun ComposeUIViewController( - configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, - content: @Composable () -> Unit -): UIViewController = - ComposeWindow( - configuration = ComposeUIViewControllerConfiguration().apply(configure), - content = content, - ) - -// FIXME: It's better to rename it now -private class AttachedComposeContext( - val scene: ComposeScene, - val view: SkikoUIView, - val interopContext: UIKitInteropContext -) { - private var constraints: List = emptyList() - set(value) { - if (field.isNotEmpty()) { - NSLayoutConstraint.deactivateConstraints(field) - } - field = value - NSLayoutConstraint.activateConstraints(value) - } - - fun setConstraintsToCenterInView(parentView: UIView, size: CValue) { - 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() - // After scene is disposed all UIKit interop actions can't be deferred to be synchronized with rendering - // Thus they need to be executed now. - interopContext.retrieve().actions.forEach { it.invoke() } - view.dispose() - } -} - -@OptIn(InternalComposeApi::class) -@ExportObjCClass -private class ComposeWindow( - private val configuration: ComposeUIViewControllerConfiguration, - private val content: @Composable () -> Unit -) : UIViewController(nibName = null, bundle = null) { - - private var keyboardOverlapHeight by mutableStateOf(0f) - private var isInsideSwiftUI = false - private var safeArea by mutableStateOf(PlatformInsets()) - private var layoutMargins by mutableStateOf(PlatformInsets()) - - //invisible view to track system keyboard animation - private val keyboardAnimationView: UIView by lazy { - UIView(CGRectMake(0.0, 0.0, 0.0, 0.0)).apply { - hidden = true - } - } - private var keyboardAnimationListener: CADisplayLink? = null - - /* - * Initial value is arbitrarily chosen to avoid propagating invalid value logic - * It's never the case in real usage scenario to reflect that in type system - */ - private var interfaceOrientation by 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() { - // Modern: https://developer.apple.com/documentation/uikit/uiwindowscene/3198088-interfaceorientation?language=objc - // Deprecated: https://developer.apple.com/documentation/uikit/uiapplication/1623026-statusbarorientation?language=objc - return if (available(OS.Ios to OSVersion(13))) { - view.window?.windowScene?.interfaceOrientation?.let { - InterfaceOrientation.getByRawValue(it) - } - } else { - InterfaceOrientation.getByRawValue(UIApplication.sharedApplication.statusBarOrientation) - } - } - - private val _windowInfo = WindowInfoImpl().apply { - isWindowFocused = true - } - - private val fontScale: Float - get() { - val contentSizeCategory = - traitCollection.preferredContentSizeCategory ?: UIContentSizeCategoryUnspecified - - return uiContentSizeCategoryToFontScaleMap[contentSizeCategory] ?: 1.0f - } - - private val density: Density - get() = Density( - attachedComposeContext?.view?.contentScaleFactor?.toFloat() ?: 1f, - fontScale - ) - - private var attachedComposeContext: AttachedComposeContext? = null - - private val keyboardVisibilityListener = object : NSObject() { - @Suppress("unused") - @ObjCAction - fun keyboardWillShow(arg: NSNotification) { - animateKeyboard(arg, true) - - val scene = attachedComposeContext?.scene ?: return - val userInfo = arg.userInfo ?: return - val keyboardInfo = userInfo[UIKeyboardFrameEndUserInfoKey] as NSValue - val keyboardHeight = keyboardInfo.CGRectValue().useContents { size.height } - if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { - val focusedRect = scene.focusManager.getFocusRect()?.toDpRect(density) - - if (focusedRect != null) { - updateViewBounds( - offsetY = calcFocusedLiftingY(focusedRect, keyboardHeight) - ) - } - } - } - - @Suppress("unused") - @ObjCAction - fun keyboardWillHide(arg: NSNotification) { - animateKeyboard(arg, false) - - if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { - updateViewBounds(offsetY = 0.0) - } - } - - private fun animateKeyboard(arg: NSNotification, isShow: Boolean) { - val userInfo = arg.userInfo!! - - //return actual keyboard height during animation - fun getCurrentKeyboardHeight(): CGFloat { - val layer = keyboardAnimationView.layer.presentationLayer() ?: return 0.0 - return layer.frame.useContents { origin.y } - } - - //attach to root view if needed - if (keyboardAnimationView.superview == null) { - this@ComposeWindow.view.addSubview(keyboardAnimationView) - } - - //cancel previous animation - keyboardAnimationView.layer.removeAllAnimations() - keyboardAnimationListener?.invalidate() - - //synchronize actual keyboard height with keyboardAnimationView without animation - val current = getCurrentKeyboardHeight() - CATransaction.begin() - CATransaction.setValue(true, kCATransactionDisableActions) - keyboardAnimationView.setFrame(CGRectMake(0.0, current, 0.0, 0.0)) - CATransaction.commit() - - //animation listener - keyboardAnimationListener = CADisplayLink.displayLinkWithTarget( - target = object : NSObject() { - val bottomIndent: CGFloat - - init { - 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 } - bottomIndent = screenHeight - composeViewBottomY - } - - @Suppress("unused") - @ObjCAction - fun animationDidUpdate() { - val currentHeight = getCurrentKeyboardHeight() - if (bottomIndent < currentHeight) { - keyboardOverlapHeight = (currentHeight - bottomIndent).toFloat() - } - } - }, - selector = sel_registerName("animationDidUpdate") - ).apply { - addToRunLoop(NSRunLoop.mainRunLoop(), NSDefaultRunLoopMode) - } - - //start system animation with duration - val duration = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? Double ?: 0.0 - val toValue: CGFloat = if (isShow) { - val keyboardInfo = userInfo[UIKeyboardFrameEndUserInfoKey] as NSValue - keyboardInfo.CGRectValue().useContents { size.height } - } else { - 0.0 - } - UIView.animateWithDuration( - duration = duration, - animations = { - //set final destination for animation - keyboardAnimationView.setFrame(CGRectMake(0.0, toValue, 0.0, 0.0)) - }, - completion = { isFinished -> - if (isFinished) { - keyboardAnimationListener?.invalidate() - keyboardAnimationListener = null - keyboardAnimationView.removeFromSuperview() - } else { - //animation was canceled by other animation - } - } - ) - } - - 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) { - view.layer.setBounds( - view.frame.useContents { - CGRectMake( - x = offsetX, - y = offsetY, - width = size.width, - height = size.height - ) - } - ) - } - } - - @Suppress("unused") - @ObjCAction - fun viewSafeAreaInsetsDidChange() { - // super.viewSafeAreaInsetsDidChange() // TODO: call super after Kotlin 1.8.20 - view.safeAreaInsets.useContents { - safeArea = PlatformInsets( - left = left.dp, - top = top.dp, - right = right.dp, - bottom = bottom.dp, - ) - } - view.directionalLayoutMargins.useContents { - layoutMargins = PlatformInsets( - left = leading.dp, // TODO: Check RTL support - top = top.dp, - right = trailing.dp, // TODO: Check RTL support - bottom = bottom.dp, - ) - } - } - - override fun loadView() { - view = UIView().apply { - backgroundColor = UIColor.whiteColor - setClipsToBounds(true) - } // rootView needs to interop with UIKit - } - - override fun viewDidLoad() { - super.viewDidLoad() - - PlistSanityCheck.performIfNeeded() - - configuration.delegate.viewDidLoad() - } - - 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 { - interfaceOrientation = it - } - - attachedComposeContext?.let { - updateLayout(it) - } - } - - private fun updateLayout(context: AttachedComposeContext) { - val scale = density.density - val size = view.frame.useContents { - IntSize( - width = (size.width * scale).roundToInt(), - height = (size.height * scale).roundToInt() - ) - } - _windowInfo.containerSize = size - context.scene.density = density - context.scene.size = size - - context.view.needRedraw() - } - - override fun viewWillTransitionToSize( - size: CValue, - 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) - } - - 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 - ) - - configuration.delegate.viewDidAppear(animated) - - } - - // 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 - ) - - configuration.delegate.viewWillDisappear(animated) - } - - override fun viewDidDisappear(animated: Boolean) { - super.viewDidDisappear(animated) - - dispose() - - dispatch_async(dispatch_get_main_queue()) { - kotlin.native.internal.GC.collect() - } - - configuration.delegate.viewDidDisappear(animated) - } - - override fun didReceiveMemoryWarning() { - println("didReceiveMemoryWarning") - kotlin.native.internal.GC.collect() - super.didReceiveMemoryWarning() - } - - private fun dispose() { - attachedComposeContext?.dispose() - attachedComposeContext = null - } - - private fun attachComposeIfNeeded() { - if (attachedComposeContext != null) { - return // already attached - } - - val skikoUIView = SkikoUIView() - - val interopContext = UIKitInteropContext(requestRedraw = skikoUIView::needRedraw) - - skikoUIView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(skikoUIView) - - 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 platformContext = object : PlatformContext by PlatformContext.Empty { - override val windowInfo: WindowInfo - get() = _windowInfo - override val textInputService: PlatformTextInputService = inputServices - override val viewConfiguration = object : ViewConfiguration by EmptyViewConfiguration { - - // this value is originating from iOS 16 drag behavior reverse engineering - 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 = DefaultInputModeManager(InputMode.Touch) - } - - val scene = MultiLayerComposeScene( - coroutineContext = Dispatchers.Main, - composeSceneContext = object : ComposeSceneContext { - override val platformContext get() = platformContext - }, - density = density, - invalidate = skikoUIView::needRedraw, - ) - val isReadyToShowContent = mutableStateOf(false) - - skikoUIView.input = inputServices.skikoInput - skikoUIView.inputTraits = inputTraits - skikoUIView.delegate = object : SkikoUIViewDelegate { - override fun onKeyboardEvent(event: SkikoKeyboardEvent) { - val composeEvent = KeyEvent(event) - if (!inputServices.onPreviewKeyEvent(composeEvent)) { - scene.sendKeyEvent(composeEvent) - } - } - - @Suppress("DEPRECATION") - override fun pointInside(point: CValue, event: UIEvent?): Boolean = - point.useContents { - val position = Offset( - (x * density.density).toFloat(), - (y * density.density).toFloat() - ) - - !scene.hitTestInteropView(position) - } - - override fun onTouchesEvent(view: UIView, event: UIEvent, phase: UITouchesEventPhase) { - val density = density.density - - scene.sendPointerEvent( - eventType = phase.toPointerEventType(), - pointers = event.touchesForView(view)?.map { - val touch = it as UITouch - val id = touch.hashCode().toLong() - - val position = touch.offsetInView(view, density) - - ComposeScenePointer( - id = PointerId(id), - position = position, - pressed = touch.isPressed, - type = PointerType.Touch, - pressure = touch.force.toFloat(), - historical = event.historicalChangesForTouch(touch, view, density) - ) - } ?: emptyList(), - timeMillis = (event.timestamp * 1e3).toLong(), - nativeEvent = event - ) - } - - override fun retrieveInteropTransaction(): UIKitInteropTransaction = - interopContext.retrieve() - - override fun render(canvas: Canvas, targetTimestamp: NSTimeInterval) { - // The calculation is split in two instead of - // `(targetTimestamp * 1e9).toLong()` - // to avoid losing precision for fractional part - val integral = floor(targetTimestamp) - val fractional = targetTimestamp - integral - val secondsToNanos = 1_000_000_000L - val nanos = - integral.roundToLong() * secondsToNanos + (fractional * 1e9).roundToLong() - - scene.render(canvas.asComposeCanvas(), nanos) - } - - override fun onAttachedToWindow() { - attachedComposeContext!!.scene.density = density - isReadyToShowContent.value = true - } - } - - scene.setContent { - if (!isReadyToShowContent.value) return@setContent - CompositionLocalProvider( - LocalLayerContainer provides view, - LocalUIViewController provides this, - LocalKeyboardOverlapHeight provides keyboardOverlapHeight, - LocalSafeArea provides safeArea, - LocalLayoutMargins provides layoutMargins, - LocalInterfaceOrientation provides interfaceOrientation, - LocalSystemTheme provides systemTheme.value, - LocalUIKitInteropContext provides interopContext, - content = content - ) - } - - attachedComposeContext = - AttachedComposeContext(scene, skikoUIView, interopContext).also { - it.setConstraintsToFillView(view) - updateLayout(it) - } - } -} - -private fun UITouch.offsetInView(view: UIView, density: Float): Offset = - locationInView(view).useContents { - Offset(x.toFloat() * density, y.toFloat() * density) - } - -private fun UIEvent.historicalChangesForTouch(touch: UITouch, view: UIView, density: Float): List { - val touches = coalescedTouchesForTouch(touch) ?: return emptyList() - - return if (touches.size > 1) { - // subList last index is exclusive, so the last touch in the list is not included - // because it's the actual touch for which coalesced touches were requested - touches.subList(0, touches.size - 1).map { - val historicalTouch = it as UITouch - HistoricalChange( - uptimeMillis = (historicalTouch.timestamp * 1e3).toLong(), - position = historicalTouch.offsetInView(view, density) - ) - } - } else { - emptyList() - } -} - -private val UITouch.isPressed - get() = when (phase) { - UITouchPhase.UITouchPhaseEnded, UITouchPhase.UITouchPhaseCancelled -> false - else -> true - } - -private fun UITouchesEventPhase.toPointerEventType(): PointerEventType = - when (this) { - UITouchesEventPhase.BEGAN -> PointerEventType.Press - UITouchesEventPhase.MOVED -> PointerEventType.Move - UITouchesEventPhase.ENDED -> PointerEventType.Release - UITouchesEventPhase.CANCELLED -> PointerEventType.Release - } - -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 - UIUserInterfaceStyle.UIUserInterfaceStyleDark -> SystemTheme.Dark - else -> SystemTheme.Unknown - } -} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/DensityProvider.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/DensityProvider.uikit.kt new file mode 100644 index 0000000000000..6a08f8a7e2209 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/DensityProvider.uikit.kt @@ -0,0 +1,83 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.ui.unit.Density +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityMedium +import platform.UIKit.UIContentSizeCategoryExtraExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraSmall +import platform.UIKit.UIContentSizeCategoryLarge +import platform.UIKit.UIContentSizeCategoryMedium +import platform.UIKit.UIContentSizeCategorySmall +import platform.UIKit.UIContentSizeCategoryUnspecified +import platform.UIKit.UIView +import platform.UIKit.UIViewController + +/** + * We need DensityProvider as a separate interface for lazy initialization. + */ +internal interface DensityProvider { + val density: Density + operator fun invoke() = density +} + +internal class DensityProviderImpl( + private val uiViewControllerProvider: () -> UIViewController, + private val viewProvider: () -> UIView, +) : DensityProvider { + + val uiViewController get() = uiViewControllerProvider() + + override val density: Density + get() { + val contentSizeCategory = + uiViewController.traitCollection.preferredContentSizeCategory + ?: UIContentSizeCategoryUnspecified + + return Density( + density = viewProvider().contentScaleFactor.toFloat(), + fontScale = uiContentSizeCategoryToFontScaleMap[contentSizeCategory] ?: 1.0f + ) + } +} + +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 +) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/FocusStack.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/FocusStack.uikit.kt new file mode 100644 index 0000000000000..ff1efcc5845fa --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/FocusStack.uikit.kt @@ -0,0 +1,66 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.ui.util.fastForEachReversed +import platform.UIKit.UIView + +/** + * Stack to remember previously focused UIView. + */ +internal interface FocusStack { + + /** + * Add new view to stack and focus on it. + */ + fun pushAndFocus(view: V) + + /** + * Pop all elements until some element. Also pop this element too. + * Last remaining element in Stack will be focused. + */ + fun popUntilNext(view: V) + + /** + * Return first added view or null + */ + fun first(): V? +} + +internal class FocusStackImpl : FocusStack { + + private var list = emptyList() + + override fun pushAndFocus(view: UIView) { + list += view + view.becomeFirstResponder() + } + + override fun popUntilNext(view: UIView) { + if (list.contains(view)) { + val index = list.indexOf(view) + list.subList(index, list.lastIndex).fastForEachReversed { + it.resignFirstResponder() + } + list = list.subList(0, index) + list.lastOrNull()?.becomeFirstResponder() + } + } + + override fun first(): UIView? = list.firstOrNull() + +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIView.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt.kt similarity index 68% rename from compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIView.kt rename to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt.kt index 58989c8243c1c..2ea535cbeb8b1 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIView.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt.kt @@ -16,221 +16,83 @@ package androidx.compose.ui.window -import androidx.compose.ui.interop.UIKitInteropAction -import androidx.compose.ui.interop.UIKitInteropTransaction 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.Rect -import platform.CoreGraphics.* -import platform.Foundation.* -import platform.Metal.MTLCreateSystemDefaultDevice -import platform.Metal.MTLDeviceProtocol -import platform.Metal.MTLPixelFormatBGRA8Unorm -import platform.QuartzCore.CAMetalLayer -import platform.UIKit.* +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CValue +import kotlinx.cinterop.readValue +import kotlinx.cinterop.useContents +import platform.CoreGraphics.CGPoint +import platform.CoreGraphics.CGRect +import platform.CoreGraphics.CGRectIntersectsRect +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGRectNull +import platform.CoreGraphics.CGRectZero +import platform.Foundation.NSComparisonResult +import platform.Foundation.NSDictionary +import platform.Foundation.NSOrderedAscending +import platform.Foundation.NSOrderedDescending +import platform.Foundation.NSOrderedSame +import platform.Foundation.NSRange +import platform.Foundation.NSSelectorFromString +import platform.Foundation.dictionary +import platform.UIKit.NSWritingDirection +import platform.UIKit.NSWritingDirectionLeftToRight +import platform.UIKit.UIKeyInputProtocol +import platform.UIKit.UIKeyboardAppearance +import platform.UIKit.UIKeyboardType +import platform.UIKit.UIMenuController +import platform.UIKit.UIPressesEvent +import platform.UIKit.UIResponderStandardEditActionsProtocol +import platform.UIKit.UIReturnKeyType +import platform.UIKit.UITextAutocapitalizationType +import platform.UIKit.UITextAutocorrectionType +import platform.UIKit.UITextContentType +import platform.UIKit.UITextDirection +import platform.UIKit.UITextGranularity +import platform.UIKit.UITextInputDelegateProtocol +import platform.UIKit.UITextInputProtocol +import platform.UIKit.UITextInputStringTokenizer +import platform.UIKit.UITextInputTokenizerProtocol +import platform.UIKit.UITextLayoutDirection +import platform.UIKit.UITextLayoutDirectionDown +import platform.UIKit.UITextLayoutDirectionLeft +import platform.UIKit.UITextLayoutDirectionRight +import platform.UIKit.UITextLayoutDirectionUp +import platform.UIKit.UITextPosition +import platform.UIKit.UITextRange +import platform.UIKit.UITextSelectionRect +import platform.UIKit.UITextStorageDirection +import platform.UIKit.UIView import platform.darwin.NSInteger -import org.jetbrains.skia.Surface -import org.jetbrains.skia.Canvas -import org.jetbrains.skiko.SkikoInputModifiers -import org.jetbrains.skiko.SkikoKey -import org.jetbrains.skiko.SkikoKeyboardEvent -import org.jetbrains.skiko.SkikoKeyboardEventKind -import org.jetbrains.skiko.SkikoPointer -import org.jetbrains.skiko.SkikoPointerDevice -import org.jetbrains.skiko.SkikoPointerEvent -import org.jetbrains.skiko.SkikoPointerEventKind - - -internal interface SkikoUIViewDelegate { - fun onKeyboardEvent(event: SkikoKeyboardEvent) - - fun pointInside(point: CValue, event: UIEvent?): Boolean - - fun onTouchesEvent(view: UIView, event: UIEvent, phase: UITouchesEventPhase) - - fun retrieveInteropTransaction(): UIKitInteropTransaction - - fun render(canvas: Canvas, targetTimestamp: NSTimeInterval) - - /** - * A callback invoked when [UIView.didMoveToWindow] receives non null window - */ - fun onAttachedToWindow() {} -} - -internal enum class UITouchesEventPhase { - BEGAN, MOVED, ENDED, CANCELLED -} +/** + * Hidden UIView to interact with iOS Keyboard and TextInput system. + * TODO maybe need to call reloadInputViews() to update UIKit text features? + */ @Suppress("CONFLICTING_OVERLOADS") -internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { - companion object : UIViewMeta() { - override fun layerClass() = CAMetalLayer - } - - @Suppress("UNUSED") // required for Objective-C - @OverrideInit - constructor(coder: NSCoder) : super(coder) { - throw UnsupportedOperationException("init(coder: NSCoder) is not supported for SkikoUIView") - } - - var delegate: SkikoUIViewDelegate? = null - var input: IOSSkikoInput? = null - var inputTraits: SkikoUITextInputTraits = object : SkikoUITextInputTraits {} - - private val _device: MTLDeviceProtocol = - MTLCreateSystemDefaultDevice() ?: throw IllegalStateException("Metal is not supported on this system") - private val _metalLayer: CAMetalLayer get() = layer as CAMetalLayer +internal class IntermediateTextInputUIView( + private val keyboardEventHandler: KeyboardEventHandler, +) : UIView(frame = CGRectZero.readValue()), + UIKeyInputProtocol, UITextInputProtocol { private var _inputDelegate: UITextInputDelegateProtocol? = null + var input: IOSSkikoInput? = null private var _currentTextMenuActions: TextActions? = null - private val _redrawer: MetalRedrawer = MetalRedrawer( - _metalLayer, - callbacks = object : MetalRedrawerCallbacks { - override fun render(canvas: Canvas, targetTimestamp: NSTimeInterval) { - delegate?.render(canvas, targetTimestamp) - } - - override fun retrieveInteropTransaction(): UIKitInteropTransaction = - delegate?.retrieveInteropTransaction() ?: UIKitInteropTransaction.empty - - } - ) - - /* - * When there at least one tracked touch, we need notify redrawer about it. It should schedule CADisplayLink which - * affects frequency of polling UITouch events on high frequency display and forces it to match display refresh rate. - */ - private var _touchesCount = 0 - set(value) { - field = value - - val needHighFrequencyPolling = value > 0 - - _redrawer.needsProactiveDisplayLink = needHighFrequencyPolling - } - - constructor() : super(frame = CGRectZero.readValue()) - - init { - multipleTouchEnabled = true - - _metalLayer.also { - // Workaround for KN compiler bug - // Type mismatch: inferred type is platform.Metal.MTLDeviceProtocol but objcnames.protocols.MTLDeviceProtocol? was expected - @Suppress("USELESS_CAST") - it.device = _device as objcnames.protocols.MTLDeviceProtocol? - - it.pixelFormat = MTLPixelFormatBGRA8Unorm - doubleArrayOf(0.0, 0.0, 0.0, 0.0).usePinned { pinned -> - it.backgroundColor = CGColorCreate(CGColorSpaceCreateDeviceRGB(), pinned.addressOf(0)) - } - it.setOpaque(true) - it.framebufferOnly = false - } - } - - fun needRedraw() = _redrawer.needRedraw() - - var isForcedToPresentWithTransactionEveryFrame by _redrawer::isForcedToPresentWithTransactionEveryFrame - - /** - * Show copy/paste text menu - * @param targetRect - rectangle of selected text area - * @param textActions - available (not null) actions in text menu - */ - fun showTextMenu(targetRect: Rect, textActions: TextActions) { - _currentTextMenuActions = textActions - val menu: UIMenuController = UIMenuController.sharedMenuController() - val cgRect = CGRectMake( - x = targetRect.left.toDouble(), - y = targetRect.top.toDouble(), - width = targetRect.width.toDouble(), - height = targetRect.height.toDouble() - ) - val isTargetVisible = CGRectIntersectsRect(bounds, cgRect) - if (isTargetVisible) { - if (menu.isMenuVisible()) { - menu.setTargetRect(cgRect, this) - } else { - menu.showMenuFromView(this, cgRect) - } - } else { - if (menu.isMenuVisible()) { - menu.hideMenu() - } - } - } - - fun hideTextMenu() { - _currentTextMenuActions = null - val menu: UIMenuController = UIMenuController.sharedMenuController() - menu.hideMenu() - } - - fun isTextMenuShown(): Boolean { - return _currentTextMenuActions != null - } - - override fun copy(sender: Any?) { - _currentTextMenuActions?.copy?.invoke() - } - - override fun paste(sender: Any?) { - _currentTextMenuActions?.paste?.invoke() - } - - override fun cut(sender: Any?) { - _currentTextMenuActions?.cut?.invoke() - } - - override fun selectAll(sender: Any?) { - _currentTextMenuActions?.selectAll?.invoke() - } - - fun dispose() { - _redrawer.dispose() - removeFromSuperview() - } + var inputTraits: SkikoUITextInputTraits = object : SkikoUITextInputTraits {} - override fun didMoveToWindow() { - super.didMoveToWindow() + override fun canBecomeFirstResponder() = true - window?.screen?.let { - contentScaleFactor = it.scale - _redrawer.maximumFramesPerSecond = it.maximumFramesPerSecond - } - if (window != null) { - delegate?.onAttachedToWindow() - } + override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { + handleUIViewPressesBegan(keyboardEventHandler, presses, withEvent) + super.pressesBegan(presses, withEvent) } - override fun layoutSubviews() { - super.layoutSubviews() - - val scaledSize = bounds.useContents { - CGSizeMake(size.width * contentScaleFactor, size.height * contentScaleFactor) - } - - // If drawableSize is zero in any dimension it means that it's a first layout - // we need to synchronously dispatch first draw and block until it's presented - // so user doesn't have a flicker - val needsSynchronousDraw = _metalLayer.drawableSize.useContents { - width == 0.0 || height == 0.0 - } - - _metalLayer.drawableSize = scaledSize - - if (needsSynchronousDraw) { - _redrawer.drawSynchronously() - } + override fun pressesEnded(presses: Set<*>, withEvent: UIPressesEvent?) { + handleUIViewPressesEnded(keyboardEventHandler, presses, withEvent) + super.pressesEnded(presses, withEvent) } - fun showScreenKeyboard() = becomeFirstResponder() - fun hideScreenKeyboard() = resignFirstResponder() - fun isScreenKeyboardOpen() = isFirstResponder - /** * A Boolean value that indicates whether the text-entry object has any text. * https://developer.apple.com/documentation/uikit/uikeyinput/1614457-hastext @@ -258,81 +120,6 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { input?.deleteBackward() } - override fun canBecomeFirstResponder() = true - - override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { - if (withEvent != null) { - for (press in withEvent.allPresses) { - val uiPress = press as? UIPress - if (uiPress != null) { - delegate?.onKeyboardEvent( - toSkikoKeyboardEvent(press, SkikoKeyboardEventKind.DOWN) - ) - } - } - } - super.pressesBegan(presses, withEvent) - } - - override fun pressesEnded(presses: Set<*>, withEvent: UIPressesEvent?) { - if (withEvent != null) { - for (press in withEvent.allPresses) { - val uiPress = press as? UIPress - if (uiPress != null) { - delegate?.onKeyboardEvent( - toSkikoKeyboardEvent(press, SkikoKeyboardEventKind.UP) - ) - } - } - } - super.pressesEnded(presses, withEvent) - } - - /** - * https://developer.apple.com/documentation/uikit/uiview/1622533-point - */ - override fun pointInside(point: CValue, withEvent: UIEvent?): Boolean = - delegate?.pointInside(point, withEvent) ?: super.pointInside(point, withEvent) - - - override fun touchesBegan(touches: Set<*>, withEvent: UIEvent?) { - super.touchesBegan(touches, withEvent) - - _touchesCount += touches.size - - withEvent?.let { event -> - delegate?.onTouchesEvent(this, event, UITouchesEventPhase.BEGAN) - } - } - - override fun touchesEnded(touches: Set<*>, withEvent: UIEvent?) { - super.touchesEnded(touches, withEvent) - - _touchesCount -= touches.size - - withEvent?.let { event -> - delegate?.onTouchesEvent(this, event, UITouchesEventPhase.ENDED) - } - } - - override fun touchesMoved(touches: Set<*>, withEvent: UIEvent?) { - super.touchesMoved(touches, withEvent) - - withEvent?.let { event -> - delegate?.onTouchesEvent(this, event, UITouchesEventPhase.MOVED) - } - } - - override fun touchesCancelled(touches: Set<*>, withEvent: UIEvent?) { - super.touchesCancelled(touches, withEvent) - - _touchesCount -= touches.size - - withEvent?.let { event -> - delegate?.onTouchesEvent(this, event, UITouchesEventPhase.CANCELLED) - } - } - override fun inputDelegate(): UITextInputDelegateProtocol? { return _inputDelegate } @@ -437,7 +224,10 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { /** * Attention! fromPosition and toPosition may be null */ - override fun textRangeFromPosition(fromPosition: UITextPosition, toPosition: UITextPosition): UITextRange? { + override fun textRangeFromPosition( + fromPosition: UITextPosition, + toPosition: UITextPosition + ): UITextRange? { val from = (fromPosition as? IntermediateTextPosition)?.position ?: 0 val to = (toPosition as? IntermediateTextPosition)?.position ?: 0 return IntermediateTextRange( @@ -452,7 +242,10 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { * @param offset a character offset from position. It can be a positive or negative value. * Offset should be considered as a number of Unicode characters. One Unicode character can contain several bytes. */ - override fun positionFromPosition(position: UITextPosition, offset: NSInteger): UITextPosition? { + override fun positionFromPosition( + position: UITextPosition, + offset: NSInteger + ): UITextPosition? { val p = (position as? IntermediateTextPosition)?.position ?: return null val endOfDocument = input?.endOfDocument() return if (endOfDocument != null) { @@ -480,7 +273,10 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { /** * Attention! position and toPosition may be null */ - override fun comparePosition(position: UITextPosition, toPosition: UITextPosition): NSComparisonResult { + override fun comparePosition( + position: UITextPosition, + toPosition: UITextPosition + ): NSComparisonResult { val from = (position as? IntermediateTextPosition)?.position ?: 0 val to = (toPosition as? IntermediateTextPosition)?.position ?: 0 val result = if (from < to) { @@ -503,120 +299,16 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { return toPosition.position - from.position } - override fun tokenizer(): UITextInputTokenizerProtocol = @Suppress("CONFLICTING_OVERLOADS") object : UITextInputStringTokenizer() { - - /** - * Return whether a text position is at a boundary of a text unit of a specified granularity in a specified direction. - * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614553-isposition?language=objc - * @param position - * A text-position object that represents a location in a document. - * @param granularity - * A constant that indicates a certain granularity of text unit. - * @param direction - * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. - * @return - * TRUE if the text position is at the given text-unit boundary in the given direction; FALSE if it is not at the boundary. - */ - override fun isPosition( - position: UITextPosition, // Attention! position may be null. - atBoundary: UITextGranularity, - inDirection: UITextDirection - ): Boolean { - if (position !is IntermediateTextPosition) { - return false - } - return input?.isPositionAtBoundary( - position = position.position.toInt(), - atBoundary = atBoundary, - inDirection = inDirection, - ) ?: false - } - - /** - * Return whether a text position is within a text unit of a specified granularity in a specified direction. - * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614491-isposition?language=objc - * @param position - * A text-position object that represents a location in a document. - * @param granularity - * A constant that indicates a certain granularity of text unit. - * @param direction - * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. - * @return - * TRUE if the text position is within a text unit of the specified granularity in the specified direction; otherwise, return FALSE. - * If the text position is at a boundary, return TRUE only if the boundary is part of the text unit in the given direction. - */ - override fun isPosition( - position: UITextPosition, // Attention! position may be null. - withinTextUnit: UITextGranularity, - inDirection: UITextDirection - ): Boolean { - if (position !is IntermediateTextPosition) { - return false - } - return input?.isPositionWithingTextUnit( - position = position.position.toInt(), - withinTextUnit = withinTextUnit, - inDirection = inDirection, - ) ?: false - } - - /** - * Return the next text position at a boundary of a text unit of the given granularity in a given direction. - * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614513-positionfromposition?language=objc - * @param position - * A text-position object that represents a location in a document. - * @param granularity - * A constant that indicates a certain granularity of text unit. - * @param direction - * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. - * @return - * The next boundary position of a text unit of the given granularity in the given direction, or nil if there is no such position. - */ - override fun positionFromPosition( - position: UITextPosition, - toBoundary: UITextGranularity, - inDirection: UITextDirection - ): UITextPosition? { - return null - if (position !is IntermediateTextPosition) { - error("position !is IntermediateTextPosition") - } - } - - /** - * Return the range for the text enclosing a text position in a text unit of a given granularity in a given direction. - * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614464-rangeenclosingposition?language=objc - * @param position - * A text-position object that represents a location in a document. - * @param granularity - * A constant that indicates a certain granularity of text unit. - * @param direction - * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. - * @return - * A text-range representing a text unit of the given granularity in the given direction, or nil if there is no such enclosing unit. - * Whether a boundary position is enclosed depends on the given direction, using the same rule as the isPosition:withinTextUnit:inDirection: method. - */ - override fun rangeEnclosingPosition( - position: UITextPosition, - withGranularity: UITextGranularity, - inDirection: UITextDirection - ): UITextRange? { - if (position !is IntermediateTextPosition) { - error("position !is IntermediateTextPosition") - } - return input?.rangeEnclosingPosition( - position = position.position.toInt(), - withGranularity = withGranularity, - inDirection = inDirection - )?.toUITextRange() - } - - } - - override fun positionWithinRange(range: UITextRange, atCharacterOffset: NSInteger): UITextPosition? = + override fun positionWithinRange( + range: UITextRange, + atCharacterOffset: NSInteger + ): UITextPosition? = TODO("positionWithinRange range: $range, atCharacterOffset: $atCharacterOffset") - override fun positionWithinRange(range: UITextRange, farthestInDirection: UITextLayoutDirection): UITextPosition? = + override fun positionWithinRange( + range: UITextRange, + farthestInDirection: UITextLayoutDirection + ): UITextPosition? = TODO("positionWithinRange, farthestInDirection: ${farthestInDirection.directionToStr()}") override fun characterRangeByExtendingPosition( @@ -639,7 +331,10 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { } } - override fun setBaseWritingDirection(writingDirection: NSWritingDirection, forRange: UITextRange) { + override fun setBaseWritingDirection( + writingDirection: NSWritingDirection, + forRange: UITextRange + ) { // TODO support RTL text direction } @@ -659,17 +354,28 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { override fun selectionRectsForRange(range: UITextRange): List<*> = listOf() override fun closestPositionToPoint(point: CValue): UITextPosition? = null - override fun closestPositionToPoint(point: CValue, withinRange: UITextRange): UITextPosition? = null + override fun closestPositionToPoint( + point: CValue, + withinRange: UITextRange + ): UITextPosition? = null + override fun characterRangeAtPoint(point: CValue): UITextRange? = null - override fun textStylingAtPosition(position: UITextPosition, inDirection: UITextStorageDirection): Map? { + override fun textStylingAtPosition( + position: UITextPosition, + inDirection: UITextStorageDirection + ): Map? { return NSDictionary.dictionary() + //TODO: Need to implement if (position !is IntermediateTextPosition) { error("position !is IntermediateTextPosition") } } - override fun characterOffsetOfPosition(position: UITextPosition, withinRange: UITextRange): NSInteger { + override fun characterOffsetOfPosition( + position: UITextPosition, + withinRange: UITextRange + ): NSInteger { if (position !is IntermediateTextPosition) { error("position !is IntermediateTextPosition") } @@ -686,29 +392,14 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { return this } - override fun canPerformAction(action: COpaquePointer?, withSender: Any?): Boolean = - when (action) { - NSSelectorFromString(UIResponderStandardEditActionsProtocol::copy.name + ":") -> - _currentTextMenuActions?.copy != null - - NSSelectorFromString(UIResponderStandardEditActionsProtocol::cut.name + ":") -> - _currentTextMenuActions?.cut != null - - NSSelectorFromString(UIResponderStandardEditActionsProtocol::paste.name + ":") -> - _currentTextMenuActions?.paste != null - - NSSelectorFromString(UIResponderStandardEditActionsProtocol::selectAll.name + ":") -> - _currentTextMenuActions?.selectAll != null - - else -> false - } - override fun keyboardType(): UIKeyboardType = inputTraits.keyboardType() override fun keyboardAppearance(): UIKeyboardAppearance = inputTraits.keyboardAppearance() override fun returnKeyType(): UIReturnKeyType = inputTraits.returnKeyType() override fun textContentType(): UITextContentType? = inputTraits.textContentType() override fun isSecureTextEntry(): Boolean = inputTraits.isSecureTextEntry() - override fun enablesReturnKeyAutomatically(): Boolean = inputTraits.enablesReturnKeyAutomatically() + override fun enablesReturnKeyAutomatically(): Boolean = + inputTraits.enablesReturnKeyAutomatically() + override fun autocapitalizationType(): UITextAutocapitalizationType = inputTraits.autocapitalizationType() @@ -749,6 +440,196 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol { fun selectionDidChange() { _inputDelegate?.selectionDidChange(this) } + + override fun isUserInteractionEnabled(): Boolean = false // disable clicks + + override fun canPerformAction(action: COpaquePointer?, withSender: Any?): Boolean { + return when (action) { + NSSelectorFromString(UIResponderStandardEditActionsProtocol::copy.name + ":") -> + _currentTextMenuActions?.copy != null + + NSSelectorFromString(UIResponderStandardEditActionsProtocol::cut.name + ":") -> + _currentTextMenuActions?.cut != null + + NSSelectorFromString(UIResponderStandardEditActionsProtocol::paste.name + ":") -> + _currentTextMenuActions?.paste != null + + NSSelectorFromString(UIResponderStandardEditActionsProtocol::selectAll.name + ":") -> + _currentTextMenuActions?.selectAll != null + + else -> false + } + } + + /** + * Show copy/paste text menu + * @param targetRect - rectangle of selected text area + * @param textActions - available (not null) actions in text menu + */ + fun showTextMenu(targetRect: org.jetbrains.skia.Rect, textActions: TextActions) { + _currentTextMenuActions = textActions + val menu: UIMenuController = UIMenuController.sharedMenuController() + val cgRect = CGRectMake( + x = targetRect.left.toDouble(), + y = targetRect.top.toDouble(), + width = targetRect.width.toDouble(), + height = targetRect.height.toDouble() + ) + val isTargetVisible = CGRectIntersectsRect(bounds, cgRect) + if (isTargetVisible) { + if (menu.isMenuVisible()) { + menu.setTargetRect(cgRect, this) + } else { + //TODO: UIMenuController.showMenuFromView is Deprecated since iOS 17 + // and not available on iOS 12 + menu.showMenuFromView(this, cgRect) + } + } else { + if (menu.isMenuVisible()) { + //TODO: UIMenuController.hideMenu is Deprecated since iOS 17 + // and not available on iOS 12 + menu.hideMenu() + } + } + } + + fun hideTextMenu() { + _currentTextMenuActions = null + val menu: UIMenuController = UIMenuController.sharedMenuController() + menu.hideMenu() + } + + fun isTextMenuShown(): Boolean { + return _currentTextMenuActions != null + } + + override fun copy(sender: Any?) { + _currentTextMenuActions?.copy?.invoke() + } + + override fun paste(sender: Any?) { + _currentTextMenuActions?.paste?.invoke() + } + + override fun cut(sender: Any?) { + _currentTextMenuActions?.cut?.invoke() + } + + override fun selectAll(sender: Any?) { + _currentTextMenuActions?.selectAll?.invoke() + } + + override fun tokenizer(): UITextInputTokenizerProtocol = object : UITextInputStringTokenizer() { + + /** + * Return whether a text position is at a boundary of a text unit of a specified granularity in a specified direction. + * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614553-isposition?language=objc + * @param position + * A text-position object that represents a location in a document. + * @param atBoundary + * A constant that indicates a certain granularity of text unit. + * @param inDirection + * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. + * @return + * TRUE if the text position is at the given text-unit boundary in the given direction; FALSE if it is not at the boundary. + */ + override fun isPosition( + position: UITextPosition, // Attention! position may be null. + atBoundary: UITextGranularity, + inDirection: UITextDirection + ): Boolean { + if (position !is IntermediateTextPosition) { + return false + } + return input?.isPositionAtBoundary( + position = position.position.toInt(), + atBoundary = atBoundary, + inDirection = inDirection, + ) ?: false + } + + /** + * Return whether a text position is within a text unit of a specified granularity in a specified direction. + * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614491-isposition?language=objc + * @param position + * A text-position object that represents a location in a document. + * @param withinTextUnit + * A constant that indicates a certain granularity of text unit. + * @param inDirection + * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. + * @return + * TRUE if the text position is within a text unit of the specified granularity in the specified direction; otherwise, return FALSE. + * If the text position is at a boundary, return TRUE only if the boundary is part of the text unit in the given direction. + */ + override fun isPosition( + position: UITextPosition, // Attention! position may be null. + withinTextUnit: UITextGranularity, + inDirection: UITextDirection + ): Boolean { + if (position !is IntermediateTextPosition) { + return false + } + return input?.isPositionWithingTextUnit( + position = position.position.toInt(), + withinTextUnit = withinTextUnit, + inDirection = inDirection, + ) ?: false + } + + /** + * Return the next text position at a boundary of a text unit of the given granularity in a given direction. + * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614513-positionfromposition?language=objc + * @param position + * A text-position object that represents a location in a document. + * @param toBoundary + * A constant that indicates a certain granularity of text unit. + * @param inDirection + * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. + * @return + * The next boundary position of a text unit of the given granularity in the given direction, or nil if there is no such position. + */ + override fun positionFromPosition( + position: UITextPosition, + toBoundary: UITextGranularity, + inDirection: UITextDirection + ): UITextPosition? { + //TODO: Need to implement + return null + if (position !is IntermediateTextPosition) { + error("position !is IntermediateTextPosition") + } + } + + /** + * Return the range for the text enclosing a text position in a text unit of a given granularity in a given direction. + * https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614464-rangeenclosingposition?language=objc + * @param position + * A text-position object that represents a location in a document. + * @param withGranularity + * A constant that indicates a certain granularity of text unit. + * @param inDirection + * A constant that indicates a direction relative to position. The constant can be of type UITextStorageDirection or UITextLayoutDirection. + * @return + * A text-range representing a text unit of the given granularity in the given direction, or nil if there is no such enclosing unit. + * Whether a boundary position is enclosed depends on the given direction, using the same rule as the isPosition:withinTextUnit:inDirection: method. + */ + override fun rangeEnclosingPosition( + position: UITextPosition, + withGranularity: UITextGranularity, + inDirection: UITextDirection + ): UITextRange? { + if (position !is IntermediateTextPosition) { + error("position !is IntermediateTextPosition") + } + return input?.rangeEnclosingPosition( + position = position.position.toInt(), + withGranularity = withGranularity, + inDirection = inDirection + )?.toUITextRange() + } + + } + } private class IntermediateTextPosition(val position: Long = 0) : UITextPosition() @@ -783,37 +664,5 @@ private fun NSWritingDirection.directionToStr() = UITextLayoutDirectionRight -> "Right" UITextLayoutDirectionUp -> "Up" UITextLayoutDirectionDown -> "Down" - else -> "unknown direction" - } - -private fun toSkikoKeyboardEvent( - event: UIPress, - kind: SkikoKeyboardEventKind -): SkikoKeyboardEvent { - val timestamp = (event.timestamp * 1_000).toLong() - return SkikoKeyboardEvent( - SkikoKey.valueOf(event.key!!.keyCode), - toSkikoModifiers(event), - kind, - timestamp, - event - ) -} - -private fun toSkikoModifiers(event: UIPress): SkikoInputModifiers { - var result = 0 - val modifiers = event.key!!.modifierFlags - if (modifiers and UIKeyModifierAlternate != 0L) { - result = result.or(SkikoInputModifiers.ALT.value) - } - if (modifiers and UIKeyModifierShift != 0L) { - result = result.or(SkikoInputModifiers.SHIFT.value) + else -> "Unknown" } - if (modifiers and UIKeyModifierControl != 0L) { - result = result.or(SkikoInputModifiers.CONTROL.value) - } - if (modifiers and UIKeyModifierCommand != 0L) { - result = result.or(SkikoInputModifiers.META.value) - } - return SkikoInputModifiers(result) -} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/KeyboardEventHandler.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/KeyboardEventHandler.uikit.kt new file mode 100644 index 0000000000000..b9c88bca91cdc --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/KeyboardEventHandler.uikit.kt @@ -0,0 +1,23 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import org.jetbrains.skiko.SkikoKeyboardEvent + +internal interface KeyboardEventHandler { + fun onKeyboardEvent(event: SkikoKeyboardEvent) +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/KeyboardVisibilityListener.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/KeyboardVisibilityListener.uikit.kt new file mode 100644 index 0000000000000..d59935ce7d3c5 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/KeyboardVisibilityListener.uikit.kt @@ -0,0 +1,215 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.runtime.MutableState +import androidx.compose.ui.scene.ComposeSceneFocusManager +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration +import androidx.compose.ui.uikit.OnFocusBehavior +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.toDpRect +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.useContents +import platform.CoreGraphics.CGFloat +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSDefaultRunLoopMode +import platform.Foundation.NSNotification +import platform.Foundation.NSRunLoop +import platform.Foundation.NSValue +import platform.QuartzCore.CADisplayLink +import platform.QuartzCore.CATransaction +import platform.QuartzCore.kCATransactionDisableActions +import platform.UIKit.CGRectValue +import platform.UIKit.UIKeyboardAnimationDurationUserInfoKey +import platform.UIKit.UIKeyboardFrameEndUserInfoKey +import platform.UIKit.UIScreen +import platform.UIKit.UIView +import platform.darwin.NSObject +import platform.darwin.sel_registerName + +internal interface KeyboardVisibilityListener { + fun keyboardWillShow(arg: NSNotification) + fun keyboardWillHide(arg: NSNotification) +} + +internal class KeyboardVisibilityListenerImpl( + private val configuration: ComposeUIViewControllerConfiguration, + private val keyboardOverlapHeightState: MutableState, + private val viewProvider: () -> UIView, + private val densityProvider: DensityProvider, + private val composeSceneMediatorProvider: () -> ComposeSceneMediator, + private val focusManager: ComposeSceneFocusManager, +) : KeyboardVisibilityListener { + + val view get() = viewProvider() + + //invisible view to track system keyboard animation + private val keyboardAnimationView: UIView by lazy { + UIView(CGRectMake(0.0, 0.0, 0.0, 0.0)).apply { + hidden = true + } + } + private var keyboardAnimationListener: CADisplayLink? = null + + override fun keyboardWillShow(arg: NSNotification) { + animateKeyboard(arg, true) + + val mediator = composeSceneMediatorProvider() + val userInfo = arg.userInfo ?: return + val keyboardInfo = userInfo[UIKeyboardFrameEndUserInfoKey] as NSValue + val keyboardHeight = keyboardInfo.CGRectValue().useContents { size.height } + if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { + val focusedRect = focusManager.getFocusRect()?.toDpRect(densityProvider()) + + if (focusedRect != null) { + updateViewBounds( + offsetY = calcFocusedLiftingY(mediator, focusedRect, keyboardHeight) + ) + } + } + } + + override fun keyboardWillHide(arg: NSNotification) { + animateKeyboard(arg, false) + + if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { + updateViewBounds(offsetY = 0.0) + } + } + + private fun animateKeyboard(arg: NSNotification, isShow: Boolean) { + val userInfo = arg.userInfo!! + + //return actual keyboard height during animation + fun getCurrentKeyboardHeight(): CGFloat { + val layer = keyboardAnimationView.layer.presentationLayer() ?: return 0.0 + return layer.frame.useContents { origin.y } + } + + //attach to root view if needed + if (keyboardAnimationView.superview == null) { + view.addSubview(keyboardAnimationView) + } + + //cancel previous animation + keyboardAnimationView.layer.removeAllAnimations() + keyboardAnimationListener?.invalidate() + + //synchronize actual keyboard height with keyboardAnimationView without animation + val current = getCurrentKeyboardHeight() + CATransaction.begin() + CATransaction.setValue(true, kCATransactionDisableActions) + keyboardAnimationView.setFrame(CGRectMake(0.0, current, 0.0, 0.0)) + CATransaction.commit() + + //animation listener + keyboardAnimationListener = CADisplayLink.displayLinkWithTarget( + target = object : NSObject() { + val bottomIndent: CGFloat + + init { + 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 } + bottomIndent = screenHeight - composeViewBottomY + } + + @Suppress("unused") + @ObjCAction + fun animationDidUpdate() { + val currentHeight = getCurrentKeyboardHeight() + if (bottomIndent < currentHeight) { + keyboardOverlapHeightState.value = (currentHeight - bottomIndent).toFloat() + } + } + }, + selector = sel_registerName("animationDidUpdate") + ).apply { + addToRunLoop(NSRunLoop.mainRunLoop(), NSDefaultRunLoopMode) + } + + //start system animation with duration + val duration = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? Double ?: 0.0 + val toValue: CGFloat = if (isShow) { + val keyboardInfo = userInfo[UIKeyboardFrameEndUserInfoKey] as NSValue + keyboardInfo.CGRectValue().useContents { size.height } + } else { + 0.0 + } + UIView.animateWithDuration( + duration = duration, + animations = { + //set final destination for animation + keyboardAnimationView.setFrame(CGRectMake(0.0, toValue, 0.0, 0.0)) + }, + completion = { isFinished -> + if (isFinished) { + keyboardAnimationListener?.invalidate() + keyboardAnimationListener = null + keyboardAnimationView.removeFromSuperview() + } else { + //animation was canceled by other animation + } + } + ) + } + + private fun calcFocusedLiftingY( + composeSceneMediator: ComposeSceneMediator, + focusedRect: DpRect, + keyboardHeight: Double + ): Double { + val viewHeight = composeSceneMediator.view.frame.useContents { + size.height + } + + 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) { + view.layer.setBounds( + view.frame.useContents { + CGRectMake( + x = offsetX, + y = offsetY, + width = size.width, + height = size.height + ) + } + ) + } +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.uikit.kt similarity index 98% rename from compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.kt rename to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.uikit.kt index ae6506961ccd2..c959a1e39bc10 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.uikit.kt @@ -33,7 +33,6 @@ import platform.QuartzCore.* import platform.UIKit.UIApplicationDidEnterBackgroundNotification import platform.UIKit.UIApplicationWillEnterForegroundNotification import platform.darwin.* -import kotlin.math.roundToInt import org.jetbrains.skia.Rect import platform.Foundation.NSLock import platform.Foundation.NSTimeInterval @@ -191,6 +190,7 @@ internal class InflightCommandBuffers( internal class MetalRedrawer( private val metalLayer: CAMetalLayer, private val callbacks: MetalRedrawerCallbacks, + private val transparency: Boolean, ) { // Workaround for KN compiler bug // Type mismatch: inferred type is objcnames.protocols.MTLDeviceProtocol but platform.Metal.MTLDeviceProtocol was expected @@ -236,7 +236,7 @@ internal class MetalRedrawer( // If active, make metalLayer transparent, opaque otherwise. // Rendering into opaque CAMetalLayer allows direct-to-screen optimization. - metalLayer.setOpaque(!value) + metalLayer.setOpaque(!value && !transparency) metalLayer.drawsAsynchronously = !value } @@ -334,7 +334,7 @@ internal class MetalRedrawer( width.toFloat(), height.toFloat() )).also { canvas -> - canvas.clear(Color.WHITE) + canvas.clear(if (transparency) Color.TRANSPARENT else Color.WHITE) callbacks.render(canvas, lastRenderTimestamp) } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIView.uikit.kt new file mode 100644 index 0000000000000..d054d66a61068 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIView.uikit.kt @@ -0,0 +1,274 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.interop.UIKitInteropTransaction +import kotlinx.cinterop.* +import org.jetbrains.skia.Canvas +import org.jetbrains.skiko.SkikoInputModifiers +import org.jetbrains.skiko.SkikoKey +import org.jetbrains.skiko.SkikoKeyboardEvent +import org.jetbrains.skiko.SkikoKeyboardEventKind +import platform.CoreGraphics.* +import platform.Foundation.* +import platform.Metal.MTLCreateSystemDefaultDevice +import platform.Metal.MTLDeviceProtocol +import platform.Metal.MTLPixelFormatBGRA8Unorm +import platform.QuartzCore.CAMetalLayer +import platform.UIKit.* + +internal class SkikoUIView( + private val keyboardEventHandler: KeyboardEventHandler, + private val delegate: SkikoUIViewDelegate, + private val transparency: Boolean, +) : UIView( + frame = CGRectMake( + x = 0.0, + y = 0.0, + width = 1.0, // TODO: Non-zero size need to first render with ComposeSceneLayer + height = 1.0 + ) +) { + + companion object : UIViewMeta() { + override fun layerClass() = CAMetalLayer + } + + var onAttachedToWindow: (() -> Unit)? = null + private val _isReadyToShowContent: MutableState = mutableStateOf(false) + val isReadyToShowContent: State = _isReadyToShowContent + + private val _device: MTLDeviceProtocol = + MTLCreateSystemDefaultDevice() ?: throw IllegalStateException("Metal is not supported on this system") + private val _metalLayer: CAMetalLayer get() = layer as CAMetalLayer + private val _redrawer: MetalRedrawer = MetalRedrawer( + _metalLayer, + callbacks = object : MetalRedrawerCallbacks { + override fun render(canvas: Canvas, targetTimestamp: NSTimeInterval) { + delegate.render(canvas, targetTimestamp) + } + + override fun retrieveInteropTransaction(): UIKitInteropTransaction = + delegate.retrieveInteropTransaction() + }, + transparency = transparency, + ) + + /* + * When there at least one tracked touch, we need notify redrawer about it. It should schedule CADisplayLink which + * affects frequency of polling UITouch events on high frequency display and forces it to match display refresh rate. + */ + private var _touchesCount = 0 + set(value) { + field = value + + val needHighFrequencyPolling = value > 0 + + _redrawer.needsProactiveDisplayLink = needHighFrequencyPolling + } + + init { + multipleTouchEnabled = true + userInteractionEnabled = true + opaque = !transparency + + _metalLayer.also { + // Workaround for KN compiler bug + // Type mismatch: inferred type is platform.Metal.MTLDeviceProtocol but objcnames.protocols.MTLDeviceProtocol? was expected + @Suppress("USELESS_CAST") + it.device = _device as objcnames.protocols.MTLDeviceProtocol? + + it.pixelFormat = MTLPixelFormatBGRA8Unorm + doubleArrayOf(0.0, 0.0, 0.0, 0.0).usePinned { pinned -> + it.backgroundColor = CGColorCreate(CGColorSpaceCreateDeviceRGB(), pinned.addressOf(0)) + } + it.setOpaque(!transparency)//todo check if remove + it.framebufferOnly = false + } + } + + fun needRedraw() = _redrawer.needRedraw() + + var isForcedToPresentWithTransactionEveryFrame by _redrawer::isForcedToPresentWithTransactionEveryFrame + + fun dispose() { + _redrawer.dispose() + removeFromSuperview() + } + + override fun didMoveToWindow() { + super.didMoveToWindow() + + window?.screen?.let { + contentScaleFactor = it.scale + _redrawer.maximumFramesPerSecond = it.maximumFramesPerSecond + } + if (window != null) { + onAttachedToWindow?.invoke() + _isReadyToShowContent.value = true + } + } + + override fun layoutSubviews() { + super.layoutSubviews() + updateMetalLayerSize() + } + + internal fun updateMetalLayerSize() { + val scaledSize = bounds.useContents { + CGSizeMake(size.width * contentScaleFactor, size.height * contentScaleFactor) + } + + // If drawableSize is zero in any dimension it means that it's a first layout + // we need to synchronously dispatch first draw and block until it's presented + // so user doesn't have a flicker + val needsSynchronousDraw = _metalLayer.drawableSize.useContents { + width == 0.0 || height == 0.0 + } + + _metalLayer.drawableSize = scaledSize + + if (needsSynchronousDraw) { + _redrawer.drawSynchronously() + } + } + + override fun canBecomeFirstResponder() = true + + override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { + handleUIViewPressesBegan(keyboardEventHandler, presses, withEvent) + super.pressesBegan(presses, withEvent) + } + + override fun pressesEnded(presses: Set<*>, withEvent: UIPressesEvent?) { + handleUIViewPressesEnded(keyboardEventHandler, presses, withEvent) + super.pressesEnded(presses, withEvent) + } + + /** + * https://developer.apple.com/documentation/uikit/uiview/1622533-point + */ + override fun pointInside(point: CValue, withEvent: UIEvent?): Boolean = + delegate.pointInside(point, withEvent) + + + override fun touchesBegan(touches: Set<*>, withEvent: UIEvent?) { + super.touchesBegan(touches, withEvent) + + _touchesCount += touches.size + + withEvent?.let { event -> + delegate.onTouchesEvent(this, event, UITouchesEventPhase.BEGAN) + } + } + + override fun touchesEnded(touches: Set<*>, withEvent: UIEvent?) { + super.touchesEnded(touches, withEvent) + + _touchesCount -= touches.size + + withEvent?.let { event -> + delegate.onTouchesEvent(this, event, UITouchesEventPhase.ENDED) + } + } + + override fun touchesMoved(touches: Set<*>, withEvent: UIEvent?) { + super.touchesMoved(touches, withEvent) + + withEvent?.let { event -> + delegate.onTouchesEvent(this, event, UITouchesEventPhase.MOVED) + } + } + + override fun touchesCancelled(touches: Set<*>, withEvent: UIEvent?) { + super.touchesCancelled(touches, withEvent) + + _touchesCount -= touches.size + + withEvent?.let { event -> + delegate.onTouchesEvent(this, event, UITouchesEventPhase.CANCELLED) + } + } + +} + +internal fun handleUIViewPressesBegan( + keyboardEventHandler: KeyboardEventHandler, + presses: Set<*>, + withEvent: UIPressesEvent? +) { + if (withEvent != null) { + for (press in withEvent.allPresses) { + if (press is UIPress) { + keyboardEventHandler.onKeyboardEvent( + toSkikoKeyboardEvent(press, SkikoKeyboardEventKind.DOWN) + ) + } + } + } +} + +internal fun handleUIViewPressesEnded( + keyboardEventHandler: KeyboardEventHandler, + presses: Set<*>, + withEvent: UIPressesEvent? +) { + if (withEvent != null) { + for (press in withEvent.allPresses) { + if (press is UIPress) { + keyboardEventHandler.onKeyboardEvent( + toSkikoKeyboardEvent(press, SkikoKeyboardEventKind.UP) + ) + } + } + } +} + +private fun toSkikoKeyboardEvent( + event: UIPress, + kind: SkikoKeyboardEventKind +): SkikoKeyboardEvent { + val timestamp = (event.timestamp * 1_000).toLong() + return SkikoKeyboardEvent( + SkikoKey.valueOf(event.key!!.keyCode), + toSkikoModifiers(event), + kind, + timestamp, + event + ) +} + +private fun toSkikoModifiers(event: UIPress): SkikoInputModifiers { + var result = 0 + val modifiers = event.key!!.modifierFlags + if (modifiers and UIKeyModifierAlternate != 0L) { + result = result.or(SkikoInputModifiers.ALT.value) + } + if (modifiers and UIKeyModifierShift != 0L) { + result = result.or(SkikoInputModifiers.SHIFT.value) + } + if (modifiers and UIKeyModifierControl != 0L) { + result = result.or(SkikoInputModifiers.CONTROL.value) + } + if (modifiers and UIKeyModifierCommand != 0L) { + result = result.or(SkikoInputModifiers.META.value) + } + return SkikoInputModifiers(result) +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIViewDelegate.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIViewDelegate.uikit.kt new file mode 100644 index 0000000000000..ae9f9be38ae92 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/SkikoUIViewDelegate.uikit.kt @@ -0,0 +1,153 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.input.pointer.HistoricalChange +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.interop.UIKitInteropContext +import androidx.compose.ui.interop.UIKitInteropTransaction +import androidx.compose.ui.scene.ComposeScene +import androidx.compose.ui.scene.ComposeScenePointer +import kotlin.math.floor +import kotlin.math.roundToLong +import kotlinx.cinterop.CValue +import kotlinx.cinterop.useContents +import org.jetbrains.skia.Canvas +import platform.CoreGraphics.CGPoint +import platform.Foundation.NSTimeInterval +import platform.UIKit.UIEvent +import platform.UIKit.UITouch +import platform.UIKit.UITouchPhase +import platform.UIKit.UIView + +internal enum class UITouchesEventPhase { + BEGAN, MOVED, ENDED, CANCELLED +} + +internal interface SkikoUIViewDelegate { + fun pointInside(point: CValue, event: UIEvent?): Boolean + fun onTouchesEvent(view: UIView, event: UIEvent, phase: UITouchesEventPhase) + fun retrieveInteropTransaction(): UIKitInteropTransaction + fun render(canvas: Canvas, targetTimestamp: NSTimeInterval) + var metalOffset: Offset +} + +internal class SkikoUIViewDelegateImpl( + private val sceneProvider: () -> ComposeScene, + private val interopContext: UIKitInteropContext, + private val densityProvider: DensityProvider, +) : SkikoUIViewDelegate { + val scene get() = sceneProvider() + val density get() = densityProvider() + override var metalOffset: Offset = Offset.Zero + + override fun pointInside(point: CValue, event: UIEvent?): Boolean = + point.useContents { + val position = Offset( + (x * density.density).toFloat(), + (y * density.density).toFloat() + ) + + !scene.hitTestInteropView(position) + } + + override fun onTouchesEvent(view: UIView, event: UIEvent, phase: UITouchesEventPhase) { + scene.sendPointerEvent( + eventType = phase.toPointerEventType(), + pointers = event.touchesForView(view)?.map { + val touch = it as UITouch + val id = touch.hashCode().toLong() + val position = touch.offsetInView(view, density.density) + ComposeScenePointer( + id = PointerId(id), + position = position, + pressed = touch.isPressed, + type = PointerType.Touch, + pressure = touch.force.toFloat(), + historical = event.historicalChangesForTouch(touch, view, density.density) + ) + } ?: emptyList(), + timeMillis = (event.timestamp * 1e3).toLong(), + nativeEvent = event + ) + } + + override fun retrieveInteropTransaction(): UIKitInteropTransaction = + interopContext.retrieve() + + override fun render(canvas: Canvas, targetTimestamp: NSTimeInterval) { + // The calculation is split in two instead of + // `(targetTimestamp * 1e9).toLong()` + // to avoid losing precision for fractional part + val integral = floor(targetTimestamp) + val fractional = targetTimestamp - integral + val secondsToNanos = 1_000_000_000L + val nanos = integral.roundToLong() * secondsToNanos + (fractional * 1e9).roundToLong() + val composeCanvas = canvas.asComposeCanvas() + val dx = metalOffset.x + val dy = metalOffset.y + composeCanvas.translate(dx, dy) + scene.render(composeCanvas, nanos) + composeCanvas.translate(-dx, -dy) + } + +} + +private fun UITouchesEventPhase.toPointerEventType(): PointerEventType = + when (this) { + UITouchesEventPhase.BEGAN -> PointerEventType.Press + UITouchesEventPhase.MOVED -> PointerEventType.Move + UITouchesEventPhase.ENDED -> PointerEventType.Release + UITouchesEventPhase.CANCELLED -> PointerEventType.Release + } + +private fun UIEvent.historicalChangesForTouch( + touch: UITouch, + view: UIView, + density: Float +): List { + val touches = coalescedTouchesForTouch(touch) ?: return emptyList() + + return if (touches.size > 1) { + // subList last index is exclusive, so the last touch in the list is not included + // because it's the actual touch for which coalesced touches were requested + touches.dropLast(1).map { + val historicalTouch = it as UITouch + HistoricalChange( + uptimeMillis = (historicalTouch.timestamp * 1e3).toLong(), + position = historicalTouch.offsetInView(view, density) + ) + } + } else { + emptyList() + } +} + +private val UITouch.isPressed + get() = when (phase) { + UITouchPhase.UITouchPhaseEnded, UITouchPhase.UITouchPhaseCancelled -> false + else -> true + } + +private fun UITouch.offsetInView(view: UIView, density: Float): Offset = + locationInView(view).useContents { + Offset(x.toFloat() * density, y.toFloat() * density) + } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/UIViewComposeSceneLayer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/UIViewComposeSceneLayer.uikit.kt new file mode 100644 index 0000000000000..f2a24dea8842c --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/UIViewComposeSceneLayer.uikit.kt @@ -0,0 +1,139 @@ +/* + * 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. + */ + +package androidx.compose.ui.window + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.scene.ComposeSceneContext +import androidx.compose.ui.scene.ComposeSceneLayer +import androidx.compose.ui.scene.SingleLayerComposeScene +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection +import kotlinx.cinterop.CValue +import platform.CoreGraphics.CGSize +import platform.UIKit.UIView +import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol + +internal class UIViewComposeSceneLayer( + private val composeContainer: ComposeContainer, + density: Density, + layoutDirection: LayoutDirection, + configuration: ComposeUIViewControllerConfiguration, + focusStack: FocusStack?, + windowInfo: WindowInfo, + compositionContext: CompositionContext, + compositionLocalContext: CompositionLocalContext?, + composeSceneContext: ComposeSceneContext, +) : ComposeSceneLayer { + + private val mediator = ComposeSceneMediator( + viewController = composeContainer, + configuration = configuration, + focusStack = focusStack, + windowInfo = windowInfo, + transparency = true, + ) { mediator: ComposeSceneMediator -> + SingleLayerComposeScene( + coroutineContext = compositionContext.effectCoroutineContext, + composeSceneContext = object : ComposeSceneContext by composeSceneContext { + override val platformContext get() = mediator.platformContext + }, + density = density, + invalidate = mediator::onComposeSceneInvalidate, + layoutDirection = layoutDirection, + ) + } + + init { + mediator.compositionLocalContext = compositionLocalContext + composeContainer.attachLayer(this) + } + + override var density: Density = density + set(value) { + //todo set to scene + } + override var layoutDirection: LayoutDirection = layoutDirection + set(value) { + //todo set to scene + } + override var bounds: IntRect + get() = mediator.getViewBounds() + set(value) { + mediator.setLayout( + SceneLayout.Bounds(rect = value) + ) + } + override var scrimColor: Color? = null + override var focusable: Boolean = focusStack != null + + override fun close() { + mediator.dispose() + composeContainer.detachLayer(this) + } + + override fun setContent(content: @Composable () -> Unit) { + mediator.setContent { + ProvideContainerCompositionLocals(composeContainer) { + content() + } + } + } + + override fun setKeyEventListener( + onPreviewKeyEvent: ((KeyEvent) -> Boolean)?, + onKeyEvent: ((KeyEvent) -> Boolean)? + ) { + //todo + } + + override fun setOutsidePointerEventListener( + onOutsidePointerEvent: ((mainEvent: Boolean) -> Unit)? + ) { + //todo + } + + fun viewDidAppear(animated: Boolean) { + mediator.viewDidAppear(animated) + } + + fun viewWillDisappear(animated: Boolean) { + mediator.viewWillDisappear(animated) + } + + fun viewSafeAreaInsetsDidChange() { + mediator.viewSafeAreaInsetsDidChange() + } + + fun viewWillLayoutSubviews() { + mediator.viewWillLayoutSubviews() + } + + fun viewWillTransitionToSize( + targetSize: CValue, + coordinator: UIViewControllerTransitionCoordinatorProtocol + ) { + mediator.viewWillTransitionToSize(targetSize, coordinator) + } + +} \ No newline at end of file