diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj index 5920f0cf77695..c61fbce6cb793 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 997DFCF32B18DE59000B56B5 /* MockAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCF22B18DE59000B56B5 /* MockAppDelegate.swift */; }; 997DFCF52B18E276000B56B5 /* XCTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCF42B18E276000B56B5 /* XCTestCase.swift */; }; 997DFCFD2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCFC2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift */; }; + 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */; }; 99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */; }; EA70A7EB2B27106100300068 /* CMPAccessibilityElement.m in Sources */ = {isa = PBXBuildFile; fileRef = EA70A7E82B27106100300068 /* CMPAccessibilityElement.m */; }; EA70A7EC2B27106100300068 /* CMPAccessibilityContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = EA70A7E92B27106100300068 /* CMPAccessibilityContainer.m */; }; @@ -68,6 +69,8 @@ 997DFCF42B18E276000B56B5 /* XCTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCase.swift; sourceTree = ""; }; 997DFCFA2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CMPUIKitUtilsTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 997DFCFC2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMPUIKitUtilsTestApp.swift; sourceTree = ""; }; + 99D97A862BF73A9B0035552B /* CMPEditMenuView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPEditMenuView.h; sourceTree = ""; }; + 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuView.m; sourceTree = ""; }; 99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextLoupeSession.h; sourceTree = ""; }; 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextLoupeSession.m; sourceTree = ""; }; EA70A7E62B27106100300068 /* CMPAccessibilityElement.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CMPAccessibilityElement.h; sourceTree = ""; }; @@ -129,6 +132,8 @@ EABD912A2BC02B5F00455279 /* CMPInteropWrappingView.m */, 99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */, 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */, + 99D97A862BF73A9B0035552B /* CMPEditMenuView.h */, + 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */, ); path = CMPUIKitUtils; sourceTree = ""; @@ -245,7 +250,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 1520; TargetAttributes = { 996EFEE92B02CE5D0000FE0F = { CreatedOnToolsVersion = 15.0; @@ -303,6 +308,7 @@ buildActionMask = 2147483647; files = ( 997DFCDE2B18D135000B56B5 /* CMPViewController.m in Sources */, + 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */, EABD912B2BC02B5F00455279 /* CMPInteropWrappingView.m in Sources */, EA70A7EC2B27106100300068 /* CMPAccessibilityContainer.m in Sources */, EA82F4F92B86144E00465418 /* CMPOSLogger.m in Sources */, @@ -497,6 +503,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h new file mode 100644 index 0000000000000..f32a8a7c5eff1 --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h @@ -0,0 +1,33 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +@interface CMPEditMenuView : UIView + +@property (readonly) BOOL isEditMenuShown; + +- (void)showEditMenuAtRect:(CGRect)targetRect + copy:(void (^)(void))copyBlock + cut:(void (^)(void))cutBlock + paste:(void (^)(void))pasteBlock + selectAll:(void (^)(void))selectAllBlock; + +- (void)hideEditMenu; + +- (NSTimeInterval)editMenuDelay; + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m new file mode 100644 index 0000000000000..33123699142c3 --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -0,0 +1,237 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "CMPEditMenuView.h" + +@interface CMPEditMenuView() + +@property (weak, nonatomic, nullable) UIView *rootView; + +@property (copy, nonatomic, nullable) void (^copyBlock)(void); +@property (copy, nonatomic, nullable) void (^cutBlock)(void); +@property (copy, nonatomic, nullable) void (^pasteBlock)(void); +@property (copy, nonatomic, nullable) void (^selectAllBlock)(void); + +@property (strong, nonatomic, nullable) dispatch_block_t showContextMenuBlock; +@property (strong, nonatomic, nullable) dispatch_block_t presentInteractionBlock; + +@property (assign, nonatomic) CGRect targetRect; +@property (assign, nonatomic) BOOL isEditMenuShown; +/// Due to the internal implementation of UIEditMenuInteraction, it disappears with animation when a touch is detected. +/// HACK: Keep tracking incoming touches to show UIEditMenuInteraction again after a short delay. +@property (assign, nonatomic) BOOL isPossibleTouchDetected; + +@property (readwrite) UIEditMenuInteraction* editInteraction API_AVAILABLE(ios(16.0)); + +@end + +@implementation CMPEditMenuView + +id _editInteraction; + +- (void)showEditMenuAtRect:(CGRect)targetRect + copy:(void (^)(void))copyBlock + cut:(void (^)(void))cutBlock + paste:(void (^)(void))pasteBlock + selectAll:(void (^)(void))selectAllBlock { + BOOL contextMenuItemsChanged = [self contextMenuItemsChangedCopy:copyBlock + cut:cutBlock + paste:pasteBlock + selectAll:selectAllBlock]; + BOOL positionChanged = !CGRectEqualToRect(self.targetRect, targetRect); + BOOL isTargetVisible = CGRectIntersectsRect(self.bounds, targetRect); + + if (!isTargetVisible) { + [self hideEditMenu]; + return; + } + + self.targetRect = targetRect; + self.copyBlock = copyBlock; + self.cutBlock = cutBlock; + self.pasteBlock = pasteBlock; + self.selectAllBlock = selectAllBlock; + self.isEditMenuShown = YES; + + if (@available(iOS 16, *)) { + if (self.editInteraction == nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.editInteraction = [[UIEditMenuInteraction alloc] initWithDelegate:self]; + [self addInteraction:self.editInteraction]; + [self presentEditMenuInteraction]; + self.isPossibleTouchDetected = NO; + }); + } else { + if (self.isPossibleTouchDetected) { + [self cancelPresentEditMenuInteraction]; + [self schedulePresentEditMenuInteraction]; + } else { + if (contextMenuItemsChanged) { + [self.editInteraction reloadVisibleMenu]; + } + if (positionChanged) { + [self.editInteraction updateVisibleMenuPositionAnimated:NO]; + } + } + } + } else { + if (contextMenuItemsChanged || positionChanged) { + [self hideEditMenu]; + [self scheduleShowMenuController]; + } + } +} + +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { + self.isPossibleTouchDetected = YES; + return [super hitTest:point withEvent:event]; +} + +- (void)scheduleShowMenuController { + [self cancelShowMenuController]; + + __weak __auto_type weak_self = self; + self.showContextMenuBlock = dispatch_block_create(0 ,^{ + __auto_type self = weak_self; + if (@available(iOS 13, *)) { + [[UIMenuController sharedMenuController] showMenuFromView:self rect:self.targetRect]; + } else { + [[UIMenuController sharedMenuController] setTargetRect:self.targetRect inView:self]; + [[UIMenuController sharedMenuController] setMenuVisible:YES]; + } + self.showContextMenuBlock = nil; + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)([self editMenuDelay] * NSEC_PER_SEC)), + dispatch_get_main_queue(), + self.showContextMenuBlock); +} + +- (void)cancelShowMenuController { + if (self.showContextMenuBlock != nil) { + dispatch_block_cancel(self.showContextMenuBlock); + self.showContextMenuBlock = nil; + } +} + +- (NSTimeInterval)editMenuDelay { + return 0.25; +} + +- (UIEditMenuInteraction *)editInteraction API_AVAILABLE(ios(16.0)) { + return _editInteraction; +} + +- (void)setEditInteraction:(UIEditMenuInteraction *)editInteraction API_AVAILABLE(ios(16.0)) { + _editInteraction = editInteraction; +} + +- (void)presentEditMenuInteraction API_AVAILABLE(ios(16.0)) { + NSAssert(self.editInteraction != nil, @"Edit Interaction must be initialized"); + + UIEditMenuConfiguration *config = [UIEditMenuConfiguration configurationWithIdentifier:nil + sourcePoint:self.targetRect.origin]; + [self.editInteraction presentEditMenuWithConfiguration:config]; +} + +- (void)schedulePresentEditMenuInteraction API_AVAILABLE(ios(16.0)) { + __weak __auto_type weak_self = self; + self.presentInteractionBlock = dispatch_block_create(0 ,^{ + __auto_type self = weak_self; + [self presentEditMenuInteraction]; + self.presentInteractionBlock = nil; + self.isPossibleTouchDetected = NO; + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)([self editMenuDelay] * NSEC_PER_SEC)), + dispatch_get_main_queue(), + self.presentInteractionBlock); +} + +- (void)cancelPresentEditMenuInteraction API_AVAILABLE(ios(16.0)) { + if (self.presentInteractionBlock != nil) { + dispatch_block_cancel(self.presentInteractionBlock); + } +} + +- (BOOL)canBecomeFirstResponder { + return YES; +} + +- (void)hideEditMenu { + self.isEditMenuShown = NO; + if (@available(iOS 16, *)) { + [self cancelPresentEditMenuInteraction]; + + if (self.editInteraction != nil) { + [self.editInteraction dismissMenu]; + [self removeInteraction:self.editInteraction]; + self.editInteraction = nil; + } + } else if (@available(iOS 13, *)) { + [self cancelShowMenuController]; + [[UIMenuController sharedMenuController] hideMenu]; + } else { + [self cancelShowMenuController]; + [[UIMenuController sharedMenuController] setMenuVisible:NO]; + } +} + +- (BOOL)contextMenuItemsChangedCopy:(void (^)(void))copyBlock + cut:(void (^)(void))cutBlock + paste:(void (^)(void))pasteBlock + selectAll:(void (^)(void))selectAllBlock { + return ((self.copyBlock == nil) != (copyBlock == nil) || + (self.cutBlock == nil) != (cutBlock == nil) || + (self.pasteBlock == nil) != (pasteBlock == nil) || + (self.selectAllBlock == nil) != (selectAllBlock == nil)); +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender { + return ((@selector(copy:) == action && self.copyBlock != nil) || + (@selector(paste:) == action && self.pasteBlock != nil) || + (@selector(cut:) == action && self.cutBlock != nil) || + (@selector(selectAll:) == action && self.selectAllBlock != nil)); +} + +- (void)copy:(id)sender { + if (self.copyBlock != nil) { + self.copyBlock(); + } +} + +- (void)paste:(id)sender { + if (self.pasteBlock != nil) { + self.pasteBlock(); + } +} + +- (void)cut:(id)sender { + if (self.cutBlock != nil) { + self.cutBlock(); + } +} + +- (void)selectAll:(id)sender { + if (self.selectAllBlock != nil) { + self.selectAllBlock(); + } +} + +- (CGRect)editMenuInteraction:(UIEditMenuInteraction *)interaction + targetRectForConfiguration:(UIEditMenuConfiguration *)configuration API_AVAILABLE(ios(16.0)) { + return self.targetRect; +} + +@end diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt index b68d4a774edc4..ca9283decf38d 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt @@ -21,18 +21,14 @@ import androidx.compose.ui.platform.IOSSkikoInput import androidx.compose.ui.platform.SkikoUITextInputTraits import androidx.compose.ui.platform.TextActions import androidx.compose.ui.platform.ViewConfiguration -import kotlinx.cinterop.COpaquePointer +import androidx.compose.ui.uikit.utils.CMPEditMenuView +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit import kotlinx.cinterop.CValue import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGRect -import platform.CoreGraphics.CGRectIntersectsRect import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectNull import platform.CoreGraphics.CGRectZero @@ -42,7 +38,6 @@ 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 @@ -50,9 +45,7 @@ import platform.UIKit.UIEvent 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 @@ -80,20 +73,18 @@ import platform.darwin.NSInteger @Suppress("CONFLICTING_OVERLOADS") internal class IntermediateTextInputUIView( private val viewConfiguration: ViewConfiguration -) : UIView(frame = CGRectZero.readValue()), +) : CMPEditMenuView(frame = CGRectZero.readValue()), UIKeyInputProtocol, UITextInputProtocol { - private var menuMonitoringJob = Job() private var _inputDelegate: UITextInputDelegateProtocol? = null var input: IOSSkikoInput? = null set(value) { field = value if (value == null) { - cancelContextMenuUpdate() + hideEditMenu() } } var keyboardEventHandler: KeyboardEventHandler? = null - private var _currentTextMenuActions: TextActions? = null var inputTraits: SkikoUITextInputTraits = EmptyInputTraits override fun canBecomeFirstResponder() = true @@ -466,35 +457,8 @@ internal class IntermediateTextInputUIView( 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 - } - } - - private fun shouldReloadContextMenuItems(actions: TextActions): Boolean { - return (_currentTextMenuActions?.copy == null) != (actions.copy == null) || - (_currentTextMenuActions?.paste == null) != (actions.paste == null) || - (_currentTextMenuActions?.cut == null) != (actions.cut == null) || - (_currentTextMenuActions?.selectAll == null) != (actions.selectAll == null) - } - - private fun cancelContextMenuUpdate() { - menuMonitoringJob.cancel() - menuMonitoringJob = Job() - } + override fun editMenuDelay(): Double = + viewConfiguration.doubleTapTimeoutMillis.milliseconds.toDouble(DurationUnit.SECONDS) /** * Show copy/paste text menu @@ -502,52 +466,18 @@ internal class IntermediateTextInputUIView( * @param textActions - available (not null) actions in text menu */ fun showTextMenu(targetRect: CValue, textActions: TextActions) { - val isTargetVisible = CGRectIntersectsRect(bounds, targetRect) - - if (isTargetVisible) { - // TODO: UIMenuController is deprecated since iOS 17 and not available on iOS 12 - val menu: UIMenuController = UIMenuController.sharedMenuController() - if (shouldReloadContextMenuItems(textActions)) { - menu.hideMenu() - } - cancelContextMenuUpdate() - CoroutineScope(Dispatchers.Main + menuMonitoringJob).launch { - delay(viewConfiguration.doubleTapTimeoutMillis) - menu.showMenuFromView(targetView = this@IntermediateTextInputUIView, targetRect) - } - _currentTextMenuActions = textActions - } else { - hideTextMenu() - } - } - - fun hideTextMenu() { - cancelContextMenuUpdate() - - _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() + this.showEditMenuAtRect( + targetRect = targetRect, + copy = textActions.copy, + cut = textActions.cut, + paste = textActions.paste, + selectAll = textActions.selectAll + ) } - override fun cut(sender: Any?) { - _currentTextMenuActions?.cut?.invoke() - } + fun hideTextMenu() = this.hideEditMenu() - override fun selectAll(sender: Any?) { - _currentTextMenuActions?.selectAll?.invoke() - } + fun isTextMenuShown() = isEditMenuShown override fun tokenizer(): UITextInputTokenizerProtocol = UITextInputStringTokenizer(textInput = this)