Skip to content

Commit ce216ed

Browse files
authored
Fix default context menu for BTF2 and selectable text (#2229)
Show platform context menu for BTF2 and selectable text when New Context Menu is disabled. Fixes https://youtrack.jetbrains.com/issue/CMP-7906/iOS-Trackpad.-BTF2.-Sontext-menu-doesnt-appear Fixes https://youtrack.jetbrains.com/issue/CMP-8431/iOS-Adopt-new-context-menu-API ## Release Notes N/A
1 parent 50e3651 commit ce216ed

File tree

3 files changed

+184
-11
lines changed

3 files changed

+184
-11
lines changed

compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/ContextMenu.uikit.kt

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package androidx.compose.foundation.text
1818

1919
import androidx.compose.foundation.ComposeFoundationFlags
2020
import androidx.compose.foundation.ExperimentalFoundationApi
21+
import androidx.compose.foundation.contextmenu.contextMenuGestures
2122
import androidx.compose.foundation.layout.Box
2223
import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys
2324
import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession
@@ -37,6 +38,7 @@ import androidx.compose.runtime.MutableState
3738
import androidx.compose.runtime.mutableStateOf
3839
import androidx.compose.runtime.neverEqualPolicy
3940
import androidx.compose.runtime.remember
41+
import androidx.compose.runtime.rememberCoroutineScope
4042
import androidx.compose.runtime.snapshotFlow
4143
import androidx.compose.ui.ExperimentalComposeUiApi
4244
import androidx.compose.ui.Modifier
@@ -54,12 +56,14 @@ import kotlin.time.Duration
5456
import kotlin.time.Duration.Companion.milliseconds
5557
import kotlin.time.Duration.Companion.seconds
5658
import kotlinx.coroutines.CancellableContinuation
59+
import kotlinx.coroutines.CoroutineScope
5760
import kotlinx.coroutines.FlowPreview
5861
import kotlinx.coroutines.coroutineScope
5962
import kotlinx.coroutines.delay
6063
import kotlinx.coroutines.flow.filterNotNull
6164
import kotlinx.coroutines.launch
6265
import kotlinx.coroutines.suspendCancellableCoroutine
66+
import platform.CoreGraphics.CGRectMake
6367

6468
/**
6569
* Context menu area for [BasicTextField] (with [TextFieldValue] argument).
@@ -73,7 +77,7 @@ internal actual fun ContextMenuArea(
7377
// The first time the menu is called up, the menu item provider contains a non-final set of
7478
// menu items, which causes the context menu callout to blink.
7579
// Adding a small delay resolves this issue.
76-
ProvideDefaultPlatformTextContextMenuProviders(
80+
ProvideNewContextMenuDefaultProviders(
7781
menuDelay = 100.milliseconds,
7882
modifier = manager.contextMenuAreaModifier,
7983
content = content
@@ -100,12 +104,17 @@ internal actual fun ContextMenuArea(
100104
} else {
101105
Modifier
102106
}
103-
ProvideDefaultPlatformTextContextMenuProviders(
107+
ProvideNewContextMenuDefaultProviders(
104108
modifier = modifier,
105109
content = content
106110
)
107111
} else {
108-
CommonContextMenuArea(selectionState, enabled, content)
112+
val scope = rememberCoroutineScope()
113+
PlatformContextMenu(
114+
getState = { selectionState.contextMenuItemsState(scope) },
115+
enabled = enabled,
116+
content = content
117+
)
109118
}
110119
}
111120

@@ -119,18 +128,21 @@ internal actual fun ContextMenuArea(
119128
content: @Composable () -> Unit
120129
) {
121130
if (ComposeFoundationFlags.isNewContextMenuEnabled) {
122-
ProvideDefaultPlatformTextContextMenuProviders(
131+
ProvideNewContextMenuDefaultProviders(
123132
modifier = manager.contextMenuAreaModifier,
124133
content = content
125134
)
126135
} else {
127-
CommonContextMenuArea(manager, content)
136+
PlatformContextMenu(
137+
getState = { manager.contextMenuItemsState() },
138+
content = content
139+
)
128140
}
129141
}
130142

131143
@OptIn(ExperimentalComposeUiApi::class)
132144
@Composable
133-
private fun ProvideDefaultPlatformTextContextMenuProviders(
145+
private fun ProvideNewContextMenuDefaultProviders(
134146
menuDelay: Duration = 0.seconds,
135147
modifier: Modifier = Modifier,
136148
content: @Composable () -> Unit
@@ -155,9 +167,10 @@ private fun ProvideDefaultPlatformTextContextMenuProviders(
155167
coordinates = { layoutCoordinates.value }
156168
)
157169
}
170+
158171
CompositionLocalProvider(
159-
LocalTextContextMenuToolbarProvider provides (toolbarProvider ?: provider),
160-
LocalTextContextMenuDropdownProvider provides (dropdownProvider ?: provider),
172+
LocalTextContextMenuToolbarProvider providesDefault provider,
173+
LocalTextContextMenuDropdownProvider providesDefault provider,
161174
content = {
162175
Box(
163176
modifier = modifier.onGloballyPositioned { layoutCoordinates.value = it }
@@ -168,6 +181,10 @@ private fun ProvideDefaultPlatformTextContextMenuProviders(
168181
}
169182
}
170183
)
184+
} else {
185+
Box(modifier = modifier, propagateMinConstraints = true) {
186+
content()
187+
}
171188
}
172189
}
173190

@@ -188,7 +205,7 @@ private class ContextMenuToolbarProvider(
188205
@OptIn(FlowPreview::class)
189206
override suspend fun showTextContextMenu(dataProvider: TextContextMenuDataProvider) {
190207
var session: TextContextMenuSession? = null
191-
val result = coroutineScope {
208+
coroutineScope {
192209
val job = launch {
193210
delay(menuDelay)
194211
snapshotFlow {
@@ -240,10 +257,107 @@ private class ContextMenuToolbarProvider(
240257
}
241258
job.cancel()
242259
}
243-
return result
244260
}
245261
}
246262

263+
@Composable
264+
private fun PlatformContextMenu(
265+
getState: () -> ContextMenuItemsState,
266+
enabled: Boolean = true,
267+
content: @Composable () -> Unit
268+
) {
269+
val layoutCoordinates: MutableState<LayoutCoordinates?> = remember {
270+
mutableStateOf(value = null, policy = neverEqualPolicy())
271+
}
272+
val editMenuView = remember {
273+
CMPEditMenuView().also {
274+
it.userInteractionEnabled = false
275+
}
276+
}
277+
278+
val modifier = if (enabled) {
279+
val density = LocalDensity.current
280+
Modifier.onGloballyPositioned {
281+
layoutCoordinates.value = it
282+
}.contextMenuGestures { offset ->
283+
val coordinates = layoutCoordinates.value ?: return@contextMenuGestures
284+
val layoutPosition = coordinates.positionInWindow()
285+
val layoutBounds = coordinates.boundsInWindow()
286+
287+
val state = getState()
288+
val rect = CGRectMake(
289+
x = with(density) {
290+
(offset.x + layoutPosition.x - layoutBounds.left).toDp().value.toDouble()
291+
},
292+
y = with(density) {
293+
(offset.y + layoutPosition.y - layoutBounds.top).toDp().value.toDouble()
294+
},
295+
width = 1.0,
296+
height = 1.0
297+
)
298+
editMenuView.showEditMenuAtRect(
299+
rect,
300+
state.copy,
301+
state.cut,
302+
state.paste,
303+
state.selectAll
304+
)
305+
}
306+
} else {
307+
Modifier
308+
}
309+
Box(
310+
modifier = modifier.then(ContextMenuLayoutElement(editMenuView)),
311+
propagateMinConstraints = true,
312+
) {
313+
content()
314+
}
315+
}
316+
317+
private fun TextFieldSelectionState.contextMenuItemsState(scope: CoroutineScope): ContextMenuItemsState {
318+
return ContextMenuItemsState(
319+
copy = if (canCopy()) {
320+
{ scope.launch { copy(cancelSelection = true) } }
321+
} else {
322+
null
323+
},
324+
paste = if (canPaste()) {
325+
{ scope.launch { paste() } }
326+
} else {
327+
null
328+
},
329+
cut = if (canCut()) {
330+
{ scope.launch { cut() } }
331+
} else {
332+
null
333+
},
334+
selectAll = if (canSelectAll()) {
335+
{ selectAll() }
336+
} else {
337+
null
338+
},
339+
rect = Rect.Zero
340+
)
341+
}
342+
343+
private fun SelectionManager.contextMenuItemsState(): ContextMenuItemsState {
344+
return ContextMenuItemsState(
345+
copy = if (isNonEmptySelection()) {
346+
{ copy() }
347+
} else {
348+
null
349+
},
350+
paste = null,
351+
cut = null,
352+
selectAll = if (!isEntireContainerSelected()) {
353+
{ selectAll() }
354+
} else {
355+
null
356+
},
357+
rect = Rect.Zero
358+
)
359+
}
360+
247361
private class TextContextMenuSessionImpl(
248362
val editMenuView: CMPEditMenuView,
249363
val continuation: CancellableContinuation<Unit>

compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/ContextMenuNode.uikit.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@ internal class ContextMenuLayoutNode(
106106
override fun onObservedReadsChanged() {
107107
observeReads {
108108
val previousContainerView = containerView
109-
val currentContainerView = currentValueOf(LocalUIView)
109+
val currentContainerView = try {
110+
currentValueOf(LocalUIView)
111+
} catch (_: IllegalStateException) {
112+
null
113+
}
110114
density = currentValueOf(LocalDensity)
111115

112116
if (previousContainerView != currentContainerView) {

compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,50 @@
1616

1717
#import "CMPEditMenuView.h"
1818

19+
@interface CMPEditMenuViewRegister: NSObject
20+
21+
@property (nonatomic, strong) NSMutableSet<CMPEditMenuView *> *trackedMenus;
22+
23+
@end
24+
25+
@implementation CMPEditMenuViewRegister
26+
27+
+ (instancetype)shared {
28+
static CMPEditMenuViewRegister *sharedInstance = nil;
29+
static dispatch_once_t onceToken;
30+
dispatch_once(&onceToken, ^{
31+
sharedInstance = [[self alloc] init];
32+
});
33+
return sharedInstance;
34+
}
35+
36+
- (instancetype)init {
37+
self = [super init];
38+
if (self) {
39+
_trackedMenus = [NSMutableSet new];
40+
}
41+
return self;
42+
}
43+
44+
- (void)addEditMenu:(CMPEditMenuView *)editMenu {
45+
[self.trackedMenus addObject:editMenu];
46+
}
47+
48+
- (void)removeEditMenu:(CMPEditMenuView *)editMenu {
49+
[self.trackedMenus removeObject:editMenu];
50+
}
51+
52+
- (void)hideAllMenusSkipping:(CMPEditMenuView *)skipEditMenuView {
53+
[self.trackedMenus enumerateObjectsUsingBlock:^(CMPEditMenuView * _Nonnull menuView, BOOL * _Nonnull stop) {
54+
if (menuView != skipEditMenuView) {
55+
[menuView hideEditMenu];
56+
}
57+
}];
58+
}
59+
60+
@end
61+
62+
1963
@interface CMPEditMenuView() <UIEditMenuInteractionDelegate>
2064

2165
@property (weak, nonatomic, nullable) UIView *rootView;
@@ -63,6 +107,7 @@ - (void)showEditMenuAtRect:(CGRect)targetRect
63107
self.selectAllBlock = selectAllBlock;
64108

65109
if (@available(iOS 16, *)) {
110+
[[CMPEditMenuViewRegister shared] hideAllMenusSkipping:self];
66111
if (self.editInteraction == nil || contextMenuItemsChanged || !self.isEditMenuShown) {
67112
BOOL isFirstMenuPresentation = self.presentInteractionBlock == nil;
68113
[self cancelPresentEditMenuInteraction];
@@ -80,6 +125,16 @@ - (void)showEditMenuAtRect:(CGRect)targetRect
80125
}
81126
}
82127

128+
- (void)didMoveToWindow {
129+
[super didMoveToWindow];
130+
131+
if (self.window != nil) {
132+
[[CMPEditMenuViewRegister shared] addEditMenu:self];
133+
} else {
134+
[[CMPEditMenuViewRegister shared] removeEditMenu:self];
135+
}
136+
}
137+
83138
- (void)scheduleShowMenuController {
84139
[self cancelShowMenuController];
85140

0 commit comments

Comments
 (0)