Skip to content

Commit

Permalink
Refactor Text Edit menu context actions (#1361)
Browse files Browse the repository at this point in the history
Use UIEditMenuInteraction for iOS 16+ for context menus in text fields.
Fix crash when using UIMenuController for iOS 12.

## Solution
Move all Edit Menu code to Objc lib to:
- hide `UIEditMenuInteraction` from KMM to prevent crash on launch
- keep simple and convenient interface for Edit Menu
  • Loading branch information
ASalavei committed May 23, 2024
1 parent 381796b commit c50c1dd
Show file tree
Hide file tree
Showing 4 changed files with 294 additions and 87 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -68,6 +69,8 @@
997DFCF42B18E276000B56B5 /* XCTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCase.swift; sourceTree = "<group>"; };
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 = "<group>"; };
99D97A862BF73A9B0035552B /* CMPEditMenuView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPEditMenuView.h; sourceTree = "<group>"; };
99D97A872BF73A9B0035552B /* CMPEditMenuView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuView.m; sourceTree = "<group>"; };
99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextLoupeSession.h; sourceTree = "<group>"; };
99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextLoupeSession.m; sourceTree = "<group>"; };
EA70A7E62B27106100300068 /* CMPAccessibilityElement.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CMPAccessibilityElement.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -129,6 +132,8 @@
EABD912A2BC02B5F00455279 /* CMPInteropWrappingView.m */,
99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */,
99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */,
99D97A862BF73A9B0035552B /* CMPEditMenuView.h */,
99D97A872BF73A9B0035552B /* CMPEditMenuView.m */,
);
path = CMPUIKitUtils;
sourceTree = "<group>";
Expand Down Expand Up @@ -245,7 +250,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
LastUpgradeCheck = 1520;
TargetAttributes = {
996EFEE92B02CE5D0000FE0F = {
CreatedOnToolsVersion = 15.0;
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 = "";
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <UIKit/UIKit.h>

@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
Original file line number Diff line number Diff line change
@@ -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() <UIEditMenuInteractionDelegate>

@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
Loading

0 comments on commit c50c1dd

Please sign in to comment.