From f7dc0e9144a289596816f30d829cf5f2f45fba70 Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 12:48:17 +0100 Subject: [PATCH 01/11] Copy compose from ce39fdf8982 --- compose/OWNERS | 5 +- .../animation-core/api/current.ignore | 12 +- .../animation/animation-core/api/current.txt | 14 +- .../api/restricted_current.ignore | 12 +- .../animation-core/api/restricted_current.txt | 14 +- .../animation-core/proguard-rules.pro | 24 + .../animation/core/EasingTest.android.kt | 39 +- .../androidx/compose/animation/core/Easing.kt | 17 +- .../animation-graphics/api/current.txt | 6 +- .../api/restricted_current.txt | 6 +- .../AnimatedVectorPainterResources.android.kt | 3 - .../res/AnimatedVectorResources.android.kt | 3 - .../compat/XmlAnimatedVectorParser.android.kt | 2 - .../graphics/vector/AnimatedImageVector.kt | 2 - compose/animation/animation/api/current.txt | 5 +- .../animation/api/restricted_current.txt | 5 +- .../LazyStaggeredGridWithLookahead.kt | 119 + .../lookahead/LookaheadWithLazyGridDemo.kt | 163 + .../LazyGridSharedElementDemo.kt | 157 + .../SharedElementInLazyStaggeredGridDemo.kt | 219 + .../compose/animation/AnimatedContentTest.kt | 74 + .../animation/AnimationModifierTest.kt | 144 +- .../compose/animation/SharedTransitionTest.kt | 3 +- .../compose/animation/AnimatedContent.kt | 47 +- .../compose/animation/AnimationModifier.kt | 2 +- .../animation/SharedTransitionScope.kt | 23 +- .../foundation-layout/api/current.txt | 44 +- .../api/restricted_current.txt | 46 +- .../foundation-layout/proguard-rules.pro | 2 + .../layout/ContextualFlowRowColumnTest.kt | 4 +- .../compose/foundation/layout/InsetsHelper.kt | 51 - .../layout/WindowInsetsDeviceTest.kt | 28 +- .../layout/WindowInsetsPaddingTest.kt | 29 +- .../foundation/layout/WindowInsetsSizeTest.kt | 29 +- .../foundation/layout/InsetsHelper.android.kt | 44 - .../foundation/layout/InsetsHelperTest.kt | 42 - .../foundation/foundation/api/current.ignore | 4 +- compose/foundation/foundation/api/current.txt | 139 +- .../foundation/api/desktop/foundation.api | 96 +- .../foundation/api/restricted_current.ignore | 4 +- .../foundation/api/restricted_current.txt | 139 +- .../benchmark/OverscrollBenchmark.kt | 4 +- .../demos/ExpandedTouchBoundsDemo.kt | 324 ++ .../foundation/demos/FoundationDemos.kt | 1 + .../foundation/demos/OverscrollDemo.kt | 3 + .../foundation/demos/PointerIconDemo.kt | 15 + .../foundation/demos/text/ComposeText.kt | 16 + .../foundation/demos/text/TextDemos.kt | 1 + .../demos/text2/BasicTextFieldDemos.kt | 36 + .../BaseLazyLayoutTestWithOrientation.kt | 51 + .../grid/BaseLazyGridTestWithOrientation.kt | 7 + .../foundation/lazy/grid/LazyGridTest.kt | 1074 +++++ .../list/BaseLazyListTestWithOrientation.kt | 7 + .../lazy/list/LazyListHeadersTest.kt | 3 + .../foundation/lazy/list/LazyListTest.kt | 243 ++ .../lazy/list/LazyListsIndexedTest.kt | 3 + .../BaseLazyStaggeredGridWithOrientation.kt | 7 + .../LazyStaggeredGridInLookaheadTest.kt | 1118 +++++ .../staggeredgrid/LazyStaggeredGridTest.kt | 206 +- .../samples/AnchoredDraggableSample.kt | 76 +- .../foundation/samples/OverscrollSample.kt | 86 +- .../AndroidEmbeddedExternalSurfaceTest.kt | 100 + .../compose/foundation/OverscrollTest.kt | 351 +- .../androidx/compose/foundation/ScrollTest.kt | 166 +- .../compose/foundation/ScrollableTest.kt | 42 +- .../foundation/ScrollingContainerTest.kt | 312 ++ .../StretchOverscrollIntegrationTest.kt | 3 +- .../compose/foundation/TransformableTest.kt | 157 + ...nchoredDraggableBackwardsCompatibleTest.kt | 18 +- .../AnchoredDraggableGestureTest.kt | 79 +- .../AnchoredDraggableOverscrollTest.kt | 109 +- .../AnchoredDraggableStateTest.kt | 38 + .../AnchoredDraggableTestState.kt | 38 - .../contextmenu/ContextMenuCommon.kt | 3 + .../list/BaseLazyListTestWithOrientation.kt | 3 + .../compose/foundation/pager/BasePagerTest.kt | 9 +- .../pager/PagerNestedScrollContentTest.kt | 7 + .../foundation/pager/PagerSwipeEdgeTest.kt | 31 + .../compose/foundation/pager/PagerTest.kt | 60 + .../pager/SingleParamBasePagerTest.kt | 4 +- .../foundation/selection/ToggleableTest.kt | 35 + .../foundation/text/BasicTextLinkTest.kt | 13 + .../CoreTextFieldHandwritingBoundsTest.kt | 48 +- .../CoreTextFieldHandwritingGestureTest.kt | 11 + .../foundation/text/HandwritingTestUtils.kt | 131 +- .../text/input/BasicSecureTextFieldTest.kt | 2 +- .../BasicTextFieldHandwritingBoundsTest.kt | 140 + .../text/input/BasicTextFieldSemanticsTest.kt | 9 +- .../text/input/BasicTextFieldTest.kt | 41 +- .../text/input/HandwritingDetectorTest.kt | 53 +- .../text/input/HandwritingHoverIconTest.kt | 187 + ...ansformationHardwareKeysIntegrationTest.kt | 328 ++ .../text/input/TextFieldReceiveContentTest.kt | 2 +- .../internal/AndroidTextInputSessionTest.kt | 1 + .../input/internal/BackspaceCommandTest.kt | 146 - .../internal/StatelessInputConnectionTest.kt | 122 +- .../internal/TextFieldLayoutStateCacheTest.kt | 7 +- ...InputServiceAndroidCursorAnchorInfoTest.kt | 4 + .../selection/TextFieldCursorHandleTest.kt | 31 + .../selection/TextFieldLongPressTest.kt | 2 +- .../selection/TextFieldTextToolbarTest.kt | 142 +- .../text/modifiers/AutoSizeTestUtils.kt | 15 +- .../MultiParagraphLayoutCacheTest.kt | 15 +- .../modifiers/ParagraphLayoutCacheTest.kt | 15 +- .../SelectionContainerContextMenuTest.kt | 3 + .../selection/SelectionContainerFocusTest.kt | 21 +- .../text/selection/SelectionContainerTest.kt | 98 +- .../LazyColumnMultiTextRegressionTest.kt | 20 + .../foundation/text/test/IntRectSubject.kt | 48 + .../textfield/TextFieldContextMenuTest.kt | 139 +- .../AndroidExternalSurface.android.kt | 12 +- .../foundation/AndroidOverscroll.android.kt | 223 +- .../compose/foundation/Clickable.android.kt | 34 - .../OverscrollConfiguration.android.kt | 13 + .../text/AutofillHighlight.android.kt | 38 + .../foundation/text/ContextMenu.android.kt | 11 +- .../text/TextFieldScroll.android.kt | 7 - .../text/TextPointerIcon.android.kt | 2 + .../HandwritingDetector.android.kt | 17 +- .../AndroidTextInputSession.android.kt | 28 +- .../CursorAnchorInfoController.android.kt | 1 - .../internal/HandwritingGesture.android.kt | 17 +- .../input/internal/ImeEditCommand.android.kt} | 172 +- ...egacyCursorAnchorInfoController.android.kt | 10 +- ...PlatformTextInputServiceAdapter.android.kt | 1 - .../StatelessInputConnection.android.kt | 63 +- .../internal/TextInputSession.android.kt | 10 +- .../TextFieldSelectionState.android.kt | 4 + .../TextFieldSelectionManager.android.kt | 10 + .../src/androidMain/res/values/strings.xml | 2 + .../compose/foundation/text/AutoSizeTest.kt | 79 +- .../text/input/TextFieldStateSaverTest.kt | 3 +- .../text/input/TextFieldStateTest.kt | 177 +- .../input/internal/CommitTextCommandTest.kt | 187 +- .../DeleteSurroundingTextCommandTest.kt | 270 +- ...eSurroundingTextInCodePointsCommandTest.kt | 284 +- .../FinishComposingTextCommandTest.kt | 30 +- .../text/input/internal/ImeEditCommandTest.kt | 32 + .../internal/SetComposingRegionCommandTest.kt | 125 +- .../internal/SetComposingTextCommandTest.kt | 214 +- .../input/internal/SetSelectionCommandTest.kt | 104 +- .../internal/TextFieldBufferUseFromImeTest.kt | 24 +- .../TextFieldStateInternalBufferTest.kt | 129 +- .../internal/TransformedTextFieldStateTest.kt | 216 + .../TransformedTextSelectionMovementTest.kt | 34 +- .../text/input/internal/undo/TextUndoTest.kt | 17 +- .../text/selection/SelectionManagerTest.kt | 4 +- .../TextFieldSelectionManagerTest.kt | 29 +- .../compose/foundation/BasicMarquee.kt | 52 +- .../androidx/compose/foundation/Clickable.kt | 128 +- .../foundation/ClipScrollableContainer.kt | 78 +- .../foundation/ComposeFoundationFlags.kt | 14 +- .../androidx/compose/foundation/Overscroll.kt | 276 +- .../androidx/compose/foundation/Scroll.kt | 150 +- .../compose/foundation/ScrollingContainer.kt | 252 +- .../foundation/gestures/AnchoredDraggable.kt | 140 +- .../gestures/DragGestureDetector.kt | 89 +- .../compose/foundation/gestures/Draggable.kt | 56 +- .../foundation/gestures/Draggable2D.kt | 8 +- .../gestures/MouseWheelScrollable.kt | 210 +- .../compose/foundation/gestures/Scrollable.kt | 226 +- .../foundation/gestures/Transformable.kt | 118 +- .../interaction/InteractionSource.kt | 2 +- .../compose/foundation/lazy/LazyDsl.kt | 68 + .../compose/foundation/lazy/LazyList.kt | 53 +- .../foundation/lazy/LazyListMeasure.kt | 23 +- .../compose/foundation/lazy/LazyListState.kt | 88 +- .../compose/foundation/lazy/grid/LazyGrid.kt | 51 +- .../foundation/lazy/grid/LazyGridDsl.kt | 72 + .../foundation/lazy/grid/LazyGridMeasure.kt | 135 +- .../lazy/grid/LazyGridMeasureResult.kt | 10 +- .../lazy/grid/LazyGridMeasuredItem.kt | 53 +- .../lazy/grid/LazyGridSpanLayoutProvider.kt | 4 +- .../foundation/lazy/grid/LazyGridState.kt | 84 +- .../LazyLayoutBeyondBoundsModifierLocal.kt | 33 +- .../lazy/layout/LazyLayoutItemAnimator.kt | 12 +- .../LazyLayoutScrollDeltaBetweenPasses.kt | 88 + .../lazy/layout/LazyLayoutScrollScope.kt | 326 +- .../lazy/staggeredgrid/LazyStaggeredGrid.kt | 34 +- .../staggeredgrid/LazyStaggeredGridDsl.kt | 70 + .../staggeredgrid/LazyStaggeredGridMeasure.kt | 213 +- .../LazyStaggeredGridMeasurePolicy.kt | 7 +- .../LazyStaggeredGridMeasureResult.kt | 11 +- .../staggeredgrid/LazyStaggeredGridState.kt | 97 +- .../foundation/pager/LazyLayoutPager.kt | 45 +- .../compose/foundation/pager/Pager.kt | 88 + .../compose/foundation/pager/PagerState.kt | 3 +- .../foundation/platform/Synchronization.kt} | 15 +- .../compose/foundation/text/AutoSize.kt | 135 +- .../foundation/text/AutofillHighlight.kt | 40 + .../foundation/text/BasicSecureTextField.kt | 6 +- .../compose/foundation/text/BasicText.kt | 146 +- .../compose/foundation/text/BasicTextField.kt | 44 +- .../compose/foundation/text/CoreTextField.kt | 98 +- .../foundation/text/TextFieldDelegate.kt | 3 +- .../compose/foundation/text/TextLinkScope.kt | 115 +- .../foundation/text/TextPointerIcon.kt | 5 + .../text/handwriting/StylusHandwriting.kt | 96 +- .../foundation/text/input/TextFieldBuffer.kt | 24 +- .../foundation/text/input/TextFieldState.kt | 63 +- .../internal/TextFieldDecoratorModifier.kt | 128 +- .../internal/TextFieldKeyEventHandler.kt | 82 +- .../internal/TransformedTextFieldState.kt | 151 +- .../selection/TextFieldSelectionState.kt | 29 + .../selection/TextPreparedSelection.kt | 414 +- .../input/internal/undo/TextUndoOperation.kt | 6 +- .../text/modifiers/AutoSizeTextLayoutScope.kt | 41 + .../modifiers/MultiParagraphLayoutCache.kt | 13 +- .../text/modifiers/ParagraphLayoutCache.kt | 31 +- .../SelectableTextAnnotatedStringElement.kt | 4 +- .../SelectableTextAnnotatedStringNode.kt | 6 +- .../modifiers/TextAnnotatedStringElement.kt | 4 +- .../text/modifiers/TextAnnotatedStringNode.kt | 6 +- .../text/modifiers/TextStringSimpleElement.kt | 4 +- .../text/modifiers/TextStringSimpleNode.kt | 56 +- .../selection/MultiWidgetSelectionDelegate.kt | 55 +- .../text/selection/SelectionContainer.kt | 26 +- .../text/selection/SelectionManager.kt | 1 + .../selection/TextFieldSelectionManager.kt | 16 +- ...topOverscroll.kt => Overscroll.desktop.kt} | 10 +- .../{JsOverScroll.kt => Overscroll.jsWasm.kt} | 17 +- .../compose/foundation/Overscroll.macos.kt | 10 +- .../compose/foundation/Clickable.skiko.kt | 12 - .../platform/Synchronization.skiko.kt | 34 + .../text/AutofillHighlight.skiko.kt | 28 + .../foundation/text/TextPointerIcon.skiko.kt | 5 +- ...cyPlatformTextInputServiceAdapter.skiko.kt | 3 +- .../input/internal/TextInputSession.skiko.kt | 4 +- .../compose/foundation/Overscroll.uikit.kt | 15 +- compose/integration-tests/hero/OWNERS | 3 +- compose/integration-tests/hero/README.md | 18 + .../hero/jetsnack/implementation}/Snack.kt | 7 +- .../implementation}/SnackCollection.kt | 6 +- .../jetsnack/implementation}/compose/Card.kt | 4 +- .../implementation}/compose/DestinationBar.kt | 8 +- .../implementation}/compose/Divider.kt | 4 +- .../jetsnack/implementation}/compose/Feed.kt | 6 +- .../implementation}/compose/Snacks.kt | 10 +- .../implementation}/compose/Surface.kt | 4 +- .../implementation}/compose/theme/Color.kt | 2 +- .../implementation}/compose/theme/Shape.kt | 2 +- .../implementation}/compose/theme/Theme.kt | 2 +- .../implementation}/compose/theme/Type.kt | 4 +- .../implementation}/views/DessertAdapter.kt | 6 +- .../implementation}/views/FeedAdapter.kt | 8 +- .../src/main/res/drawable-nodpi/donut.jpeg | Bin .../src/main/res/drawable-nodpi/eclair.jpeg | Bin .../main/res/drawable/arrow_forward_24.xml | 0 .../src/main/res/font/karla_regular.ttf | Bin .../main/res/layout/item_snack_card_view.xml | 0 .../src/main/res/layout/item_snack_view.xml | 0 .../src/main/res/layout/snack_feed.xml | 0 .../src/main/res/values/jetsnack_strings.xml | 0 .../src/main/AndroidManifest.xml | 10 +- .../jetsnack/macrobenchmark}/IdleTracking.kt | 4 +- .../target}/JetsnackActivity.kt | 10 +- .../target}/JetsnackViewActivity.kt | 7 +- .../res/drawable/ic_launcher_foreground.xml | 0 .../res/layout/activity_jetsnack_view.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../res/values/ic_launcher_background.xml | 0 .../integration/hero/jetsnack}/Utils.kt | 3 +- .../macrobenchmark/JetsnackConstants.kt} | 7 +- .../macrobenchmark}/JetsnackFocusBenchmark.kt | 13 +- .../JetsnackScrollBenchmark.kt | 15 +- .../JetsnackStartupBenchmark.kt | 10 +- .../microbenchmark}/JetsnackBenchmark.kt | 4 +- .../microbenchmark}/JetsnackCaseFactory.kt | 6 +- .../macrobenchmark/TrivialStartupBenchmark.kt | 2 +- .../java/androidx/compose/lint/test/Stubs.kt | 137 + .../lint/PrimitiveInCollectionDetector.kt | 30 +- .../lint/PrimitiveInCollectionDetectorTest.kt | 6 +- .../api/desktop/material-navigation.api | 2 + .../api/restricted_current.txt | 1 + .../material/navigation/NavGraphBuilder.kt | 13 +- .../material/ripple/RippleModifierNodeTest.kt | 186 +- .../compose/material/ripple/Ripple.android.kt | 18 +- .../material/ripple/RippleHostView.android.kt | 9 +- .../compose/material/ripple/Ripple.kt | 4 +- .../compose/material/ripple/RippleTheme.kt | 6 +- compose/material/material/api/current.ignore | 3 + compose/material/material/api/current.txt | 23 +- .../material/api/desktop/material.api | 6 +- .../material/api/restricted_current.ignore | 3 + .../material/api/restricted_current.txt | 23 +- .../samples/SelectionControlsSamples.kt | 2 + .../compose/material/ObservableThemeTest.kt | 2 +- .../androidx/compose/material/ScaffoldTest.kt | 72 +- .../compose/material/SlideUsingKeysTest.kt | 269 ++ .../material/ExposedDropdownMenu.android.kt | 158 +- .../compose/material/Strings.android.kt | 3 +- .../ExposedDropdownMenuPopup.android.kt | 7 +- .../src/androidMain/res/values-af/strings.xml | 21 + .../src/androidMain/res/values-am/strings.xml | 21 + .../src/androidMain/res/values-ar/strings.xml | 21 + .../src/androidMain/res/values-as/strings.xml | 21 + .../src/androidMain/res/values-az/strings.xml | 21 + .../res/values-b+sr+Latn/strings.xml | 21 + .../src/androidMain/res/values-be/strings.xml | 21 + .../src/androidMain/res/values-bg/strings.xml | 21 + .../src/androidMain/res/values-bn/strings.xml | 21 + .../src/androidMain/res/values-bs/strings.xml | 21 + .../src/androidMain/res/values-ca/strings.xml | 21 + .../src/androidMain/res/values-cs/strings.xml | 21 + .../src/androidMain/res/values-da/strings.xml | 21 + .../src/androidMain/res/values-de/strings.xml | 21 + .../src/androidMain/res/values-el/strings.xml | 21 + .../androidMain/res/values-en-rAU/strings.xml | 21 + .../androidMain/res/values-en-rCA/strings.xml | 21 + .../androidMain/res/values-en-rGB/strings.xml | 21 + .../androidMain/res/values-en-rIN/strings.xml | 21 + .../androidMain/res/values-en-rXC/strings.xml | 21 + .../androidMain/res/values-es-rUS/strings.xml | 21 + .../src/androidMain/res/values-es/strings.xml | 21 + .../src/androidMain/res/values-et/strings.xml | 21 + .../src/androidMain/res/values-eu/strings.xml | 21 + .../src/androidMain/res/values-fa/strings.xml | 21 + .../src/androidMain/res/values-fi/strings.xml | 21 + .../androidMain/res/values-fr-rCA/strings.xml | 21 + .../src/androidMain/res/values-fr/strings.xml | 21 + .../src/androidMain/res/values-gl/strings.xml | 21 + .../src/androidMain/res/values-gu/strings.xml | 21 + .../src/androidMain/res/values-hi/strings.xml | 21 + .../src/androidMain/res/values-hr/strings.xml | 21 + .../src/androidMain/res/values-hu/strings.xml | 21 + .../src/androidMain/res/values-hy/strings.xml | 21 + .../src/androidMain/res/values-in/strings.xml | 21 + .../src/androidMain/res/values-is/strings.xml | 21 + .../src/androidMain/res/values-it/strings.xml | 21 + .../src/androidMain/res/values-iw/strings.xml | 21 + .../src/androidMain/res/values-ja/strings.xml | 21 + .../src/androidMain/res/values-ka/strings.xml | 21 + .../src/androidMain/res/values-kk/strings.xml | 21 + .../src/androidMain/res/values-km/strings.xml | 21 + .../src/androidMain/res/values-kn/strings.xml | 21 + .../src/androidMain/res/values-ko/strings.xml | 21 + .../src/androidMain/res/values-ky/strings.xml | 21 + .../src/androidMain/res/values-lo/strings.xml | 21 + .../src/androidMain/res/values-lt/strings.xml | 21 + .../src/androidMain/res/values-lv/strings.xml | 21 + .../src/androidMain/res/values-mk/strings.xml | 21 + .../src/androidMain/res/values-ml/strings.xml | 21 + .../src/androidMain/res/values-mn/strings.xml | 21 + .../src/androidMain/res/values-mr/strings.xml | 21 + .../src/androidMain/res/values-ms/strings.xml | 21 + .../src/androidMain/res/values-my/strings.xml | 21 + .../src/androidMain/res/values-nb/strings.xml | 21 + .../src/androidMain/res/values-ne/strings.xml | 21 + .../src/androidMain/res/values-nl/strings.xml | 21 + .../src/androidMain/res/values-or/strings.xml | 21 + .../src/androidMain/res/values-pa/strings.xml | 21 + .../src/androidMain/res/values-pl/strings.xml | 21 + .../androidMain/res/values-pt-rBR/strings.xml | 21 + .../androidMain/res/values-pt-rPT/strings.xml | 21 + .../src/androidMain/res/values-pt/strings.xml | 21 + .../src/androidMain/res/values-ro/strings.xml | 21 + .../src/androidMain/res/values-ru/strings.xml | 21 + .../src/androidMain/res/values-si/strings.xml | 21 + .../src/androidMain/res/values-sk/strings.xml | 21 + .../src/androidMain/res/values-sl/strings.xml | 21 + .../src/androidMain/res/values-sq/strings.xml | 21 + .../src/androidMain/res/values-sr/strings.xml | 21 + .../src/androidMain/res/values-sv/strings.xml | 21 + .../src/androidMain/res/values-sw/strings.xml | 21 + .../src/androidMain/res/values-ta/strings.xml | 21 + .../src/androidMain/res/values-te/strings.xml | 21 + .../src/androidMain/res/values-th/strings.xml | 21 + .../src/androidMain/res/values-tl/strings.xml | 21 + .../src/androidMain/res/values-tr/strings.xml | 21 + .../src/androidMain/res/values-uk/strings.xml | 21 + .../src/androidMain/res/values-ur/strings.xml | 21 + .../src/androidMain/res/values-uz/strings.xml | 21 + .../src/androidMain/res/values-vi/strings.xml | 21 + .../androidMain/res/values-zh-rCN/strings.xml | 21 + .../androidMain/res/values-zh-rHK/strings.xml | 21 + .../androidMain/res/values-zh-rTW/strings.xml | 21 + .../src/androidMain/res/values-zu/strings.xml | 21 + .../src/androidMain/res/values/public.xml | 18 + .../src/androidMain/res/values/strings.xml | 21 + .../compose/material/BackdropScaffold.kt | 2 +- .../compose/material/BottomSheetScaffold.kt | 2 +- .../compose/material/ExposedDropdownMenu.kt | 169 +- .../compose/material/InternalMutatorMutex.kt | 4 +- .../compose/material/OutlinedTextField.kt | 2 +- .../androidx/compose/material/Scaffold.kt | 71 +- .../compose/material/SecureTextField.kt | 10 - .../androidx/compose/material/Slider.kt | 78 +- .../androidx/compose/material/TextField.kt | 2 +- .../internal/ExposedDropdownMenuPopup.kt | 30 + .../androidx/compose/material/AtomicActual.kt | 2 +- .../material/ExposedDropdownMenu.skiko.kt | 95 +- .../ExposedDropdownMenuPopup.skiko.kt | 51 + .../CompositionLocalNamingDetectorTest.kt | 132 +- compose/runtime/runtime/api/current.ignore | 12 +- compose/runtime/runtime/api/current.txt | 78 +- .../runtime/runtime/api/desktop/runtime.api | 6 +- .../runtime/api/restricted_current.ignore | 12 +- .../runtime/api/restricted_current.txt | 99 +- compose/runtime/runtime/build.gradle | 8 +- .../compose/runtime/RecomposerTests.kt | 6 +- .../AndroidCompositionObserverTests.kt | 12 +- .../CompositionRegistrationObserverTest.kt | 600 +++ compose/runtime/runtime/proguard-rules.pro | 6 + .../androidx/compose/runtime/Applier.kt | 10 +- .../compose/runtime/BroadcastFrameClock.kt | 4 +- .../compose/runtime/ComposeRuntimeFlags.kt | 36 + .../androidx/compose/runtime/Composer.kt | 491 ++- .../androidx/compose/runtime/Composition.kt | 21 +- .../compose/runtime/CompositionContext.kt | 7 +- .../androidx/compose/runtime/DerivedState.kt | 23 +- .../androidx/compose/runtime/Effects.kt | 4 +- .../kotlin/androidx/compose/runtime/Latch.kt | 4 +- .../compose/runtime/PausableComposition.kt | 21 +- .../compose/runtime/RecomposeScopeImpl.kt | 4 +- .../androidx/compose/runtime/Recomposer.kt | 226 +- .../androidx/compose/runtime/SlotTable.kt | 13 +- .../compose/runtime/SnapshotDoubleState.kt | 24 +- .../compose/runtime/SnapshotFloatState.kt | 24 +- .../compose/runtime/SnapshotIntState.kt | 24 +- .../compose/runtime/SnapshotLongState.kt | 24 +- .../androidx/compose/runtime/SnapshotState.kt | 25 +- .../compose/runtime/changelist/ChangeList.kt | 88 +- .../compose/runtime/changelist/Operation.kt | 163 +- .../changelist/OperationArgContainer.kt | 2 +- .../compose/runtime/changelist/Operations.kt | 386 +- .../compose/runtime/collection/Extensions.kt | 71 + .../runtime/collection/MultiValueMap.kt | 140 + .../runtime/collection/MutableVector.kt | 294 +- .../internal/RememberEventDispatcher.kt | 23 +- .../runtime/internal/SnapshotThreadLocal.kt | 6 +- .../Synchronization.kt} | 11 +- .../compose/runtime/snapshots/Snapshot.kt | 253 +- .../snapshots/SnapshotDoubleIndexHeap.kt | 23 +- .../compose/runtime/snapshots/SnapshotId.kt | 75 + .../runtime/snapshots/SnapshotIdSet.kt | 191 +- .../runtime/snapshots/SnapshotStateList.kt | 66 +- .../runtime/snapshots/SnapshotStateMap.kt | 67 +- .../snapshots/SnapshotStateObserver.kt | 10 +- .../runtime/snapshots/SnapshotStateSet.kt | 72 +- .../runtime/tooling/CompositionObserver.kt | 58 + .../Synchronization.darwin.kt} | 2 +- ...entityHashCode.jvm.kt => ActualJvm.jvm.kt} | 20 +- .../compose/runtime/Synchronization.kt | 8 +- .../platform/Synchronization.desktop.kt} | 10 +- .../runtime/snapshots/SnapshotId.js.kt | 143 + .../runtime/snapshots/SnapshotId.jvm.kt | 141 + .../OperationDefinitionValidationTest.kt | 7 +- .../runtime/changelist/OperationsTest.kt | 149 +- .../Synchronization.linux.kt} | 2 +- .../Synchronization.mingwX64.kt} | 6 +- .../runtime/snapshots/SnapshotId.native.kt | 141 + .../{ => platform}/SynchronizationTest.kt | 2 +- .../compose/runtime/AbstractApplierTest.kt | 2 +- .../compose/runtime/CompositionLocalTests.kt | 69 + .../compose/runtime/CompositionTests.kt | 91 + .../compose/runtime/MovableContentTests.kt | 236 ++ .../runtime/PausableCompositionTests.kt | 2 + .../snapshots/SnapshotDoubleIndexHeapTests.kt | 10 +- .../runtime/snapshots/SnapshotIdSetTests.kt | 134 +- .../runtime/snapshots/SnapshotTests.kt | 2 +- .../androidx/compose/runtime/LiveEditTests.kt | 4 +- .../Synchronization.posix.kt} | 6 +- .../androidx/compose/runtime/ActualJs.wasm.kt | 7 - .../runtime/snapshots/SnapshotId.wasm.kt | 141 + ...tualJsWasm.web.kt => WeakReference.web.kt} | 0 .../Synchronization.web.kt} | 5 +- compose/ui/ui-geometry/api/current.ignore | 17 + compose/ui/ui-geometry/api/current.txt | 31 +- .../ui-geometry/api/desktop/ui-geometry.api | 1 + .../ui-geometry/api/restricted_current.ignore | 17 + .../ui/ui-geometry/api/restricted_current.txt | 41 +- .../compose/ui/geometry/OffsetTest.kt | 10 +- .../compose/ui/geometry/InlineClassHelper.kt | 3 + .../androidx/compose/ui/geometry/Offset.kt | 4 +- compose/ui/ui-graphics/api/current.ignore | 22 +- compose/ui/ui-graphics/api/current.txt | 61 +- .../ui-graphics/api/restricted_current.ignore | 22 +- .../ui/ui-graphics/api/restricted_current.txt | 65 +- compose/ui/ui-graphics/proguard-rules.pro | 24 + .../layer/AndroidGraphicsLayerTest.kt | 117 +- .../layer/AndroidGraphicsLayer.android.kt | 21 +- .../layer/GraphicsViewLayer.android.kt | 2 + .../layer/RobolectricGraphicsLayerTest.kt | 104 +- .../androidx/compose/ui/graphics/Bezier.kt | 2 +- .../androidx/compose/ui/graphics/Float16.kt | 30 +- compose/ui/ui-inspection/OWNERS | 2 +- .../compose_packages_list.txt | 11 +- .../generate_compose_packages.py | 1 + .../ui/inspection/AlternateViewHelper.kt | 161 + .../ui/inspection/ComposeLayoutInspector.kt | 9 +- .../compose/ui/inspection/RootsDetector.kt | 37 + .../inspector/LayoutInspectorTree.kt | 30 +- .../ui/inspection/inspector/PackageHashes.kt | 17 +- ...ompositionLocalModifierReadDetectorTest.kt | 36 +- compose/ui/ui-test-junit4/api/current.txt | 10 +- .../ui-test-junit4/api/restricted_current.txt | 10 +- .../junit4/AndroidComposeTestRule.android.kt | 9 + .../ui/test/junit4/ComposeTestRule.jvm.kt | 5 + compose/ui/ui-test/api/current.txt | 10 +- compose/ui/ui-test/api/restricted_current.txt | 11 +- compose/ui/ui-test/build.gradle | 3 + .../DeviceConfigurationOverrideSamples.kt | 14 +- .../compose/ui/test/ComposeUiTestTest.kt | 4 +- .../compose/ui/test/ComposeUiTest.android.kt | 25 +- .../test/RobolectricIdlingStrategy.android.kt | 10 + .../compose/ui/test/TestContext.android.kt | 3 + .../compose/ui/test/RobolectricComposeTest.kt | 4 +- .../androidx/compose/ui/test/ComposeUiTest.kt | 7 +- .../androidx/compose/ui/test/Filters.kt | 3 +- .../ui/test/SemanticsNodeInteraction.kt | 37 +- .../ui/test/UncaughtExceptionHandler.kt | 9 +- .../ui/test/platform/Synchronization.kt} | 15 +- .../test/UncaughtExceptionHandler.jsMain.kt | 19 - ...ameDeferringContinuationInterceptor.jvm.kt | 4 +- .../ui/test/IdlingResourceRegistry.jvm.kt | 4 +- .../ui/test/TestMonotonicFrameClock.jvm.kt | 6 +- .../ui/test/UncaughtExceptionHandler.jvm.kt | 20 - .../UncaughtExceptionHandler.nativeMain.kt | 19 - .../ui/test/ComposeRootRegistry.skiko.kt | 4 +- .../compose/ui/test/ComposeUiTest.skiko.kt | 9 +- .../test/platform/Synchronization.skiko.kt} | 27 +- .../test/UncaughtExceptionHandler.wasmMain.kt | 19 - compose/ui/ui-text/api/current.ignore | 28 +- compose/ui/ui-text/api/current.txt | 158 +- compose/ui/ui-text/api/desktop/ui-text.api | 6 +- .../ui/ui-text/api/restricted_current.ignore | 28 +- compose/ui/ui-text/api/restricted_current.txt | 158 +- .../NonLinearFontScalingBenchmark.kt | 6 +- .../ui/text/benchmark/ParagraphBenchmark.kt | 5 +- .../benchmark/ParagraphMethodBenchmark.kt | 5 +- .../ParagraphWithLineHeightBenchmark.kt | 6 +- compose/ui/ui-text/build.gradle | 3 +- compose/ui/ui-text/lint-baseline.xml | 20 +- compose/ui/ui-text/proguard-rules.pro | 24 + .../compose/ui/text/AndroidParagraphTest.kt | 6 +- .../ui/text/MultiParagraphIntegrationTest.kt | 25 + .../ui/text/ParagraphIntegrationTest.kt | 5 +- .../text/ParagraphIntrinsicIntegrationTest.kt | 6 +- .../ui/text/android/SegmentBreakerTest.kt | 124 - .../android/TextLayoutLineVisibleEndTest.kt | 6 +- .../SegmentBreakerBreakSegmentTest.kt | 1634 -------- ...AndroidAccessibilitySpannableStringTest.kt | 20 + .../AndroidParagraphGetRangeForRectTest.kt | 2 +- .../text/platform/SpannableExtensionsTest.kt | 10 +- .../compose/ui/text/style/TextLineBreaker.kt | 2 +- .../ui/text/AndroidParagraph.android.kt | 15 +- .../androidx/compose/ui/text/Html.android.kt | 4 +- .../android/BoringLayoutFactory.android.kt | 5 +- .../ui/text/android/ListUtils.android.kt | 2 +- .../android/StaticLayoutFactory.android.kt | 13 +- .../ui/text/android/TextLayout.android.kt | 19 +- .../animation/SegmentBreaker.android.kt | 310 -- .../android/animation/SegmentType.android.kt | 40 - .../android/selection/WordIterator.android.kt | 13 +- .../style/LineHeightStyleSpan.android.kt | 5 +- .../android/style/PlaceholderSpan.android.kt | 16 +- .../font/DeviceFontFamilyNameFont.android.kt | 3 +- .../text/platform/ActualParagraph.android.kt | 9 +- ...oidAccessibilitySpannableString.android.kt | 32 +- .../AndroidFontListTypeface.android.kt | 11 +- .../AndroidParagraphHelper.android.kt | 10 +- .../AndroidParagraphIntrinsics.android.kt | 31 +- .../text/platform/AndroidTextPaint.android.kt | 12 +- .../extensions/SpannableExtensions.android.kt | 51 +- .../compose/ui/text/AnnotatedStringTest.kt | 422 +- .../compose/ui/text/AnnotatedString.kt | 375 +- .../compose/ui/text/MultiParagraph.kt | 44 +- .../ui/text/MultiParagraphIntrinsics.kt | 17 +- .../androidx/compose/ui/text/Paragraph.kt | 12 +- .../compose/ui/text/ParagraphIntrinsics.kt | 35 +- .../compose/ui/text/ParagraphStyle.kt | 5 +- .../androidx/compose/ui/text/Placeholder.kt | 5 +- .../compose/ui/text/TextLayoutResult.kt | 4 +- .../androidx/compose/ui/text/TextMeasurer.kt | 2 +- .../androidx/compose/ui/text/TextPainter.kt | 2 +- .../androidx/compose/ui/text/TextRange.kt | 21 +- .../androidx/compose/ui/text/UrlAnnotation.kt | 1 - .../compose/ui/text/font/FontFamily.kt | 3 +- .../ui/text/font/FontFamilyResolver.kt | 14 +- .../font/FontListFontFamilyTypefaceAdapter.kt | 22 +- .../compose/ui/text/font/FontMatcher.kt | 2 +- .../compose/ui/text/font/FontSynthesis.kt | 27 +- .../compose/ui/text/font/FontVariation.kt | 22 +- .../compose/ui/text/font/FontWeight.kt | 3 +- .../compose/ui/text/input/EditCommand.kt | 5 +- .../compose/ui/text/input/EditingBuffer.kt | 9 +- .../compose/ui/text/input/GapBuffer.kt | 5 +- .../compose/ui/text/input/MathUtils.kt | 14 +- .../ui/text/internal/InlineClassHelper.kt | 141 + .../compose/ui/text/intl/LocaleList.kt | 2 +- .../ui/text/platform/PlatformParagraph.kt | 7 +- .../ui/text/platform/Synchronization.kt | 14 +- .../compose/ui/text/style/LineHeightStyle.kt | 3 +- .../ui/text/style/TextForegroundStyle.kt | 3 +- .../ui/text/platform/ActualParagraph.skiko.kt | 83 +- .../text/platform/ParagraphBuilder.skiko.kt | 22 +- .../text/platform/ParagraphLayouter.skiko.kt | 10 +- .../platform/SkiaParagraphIntrinsics.skiko.kt | 10 +- .../ui/text/platform/Synchronization.skiko.kt | 35 + compose/ui/ui-tooling-preview/api/current.txt | 59 +- .../api/restricted_current.txt | 59 +- compose/ui/ui-unit/api/current.ignore | 48 +- compose/ui/ui-unit/api/current.txt | 56 +- .../ui/ui-unit/api/restricted_current.ignore | 48 +- compose/ui/ui-unit/api/restricted_current.txt | 59 +- compose/ui/ui-unit/proguard-rules.pro | 24 + .../androidx/compose/ui/unit/Constraints.kt | 46 +- compose/ui/ui-util/api/current.txt | 1 + compose/ui/ui-util/api/desktop/ui-util.api | 1 + compose/ui/ui-util/api/restricted_current.txt | 1 + compose/ui/ui-util/proguard-rules.pro | 24 + .../androidx/compose/ui/util/ListUtils.kt | 20 + .../androidx/compose/ui/util/MathHelpers.kt | 15 +- compose/ui/ui/api/current.ignore | 12 +- compose/ui/ui/api/current.txt | 371 +- compose/ui/ui/api/desktop/ui.api | 198 +- compose/ui/ui/api/restricted_current.ignore | 12 +- compose/ui/ui/api/restricted_current.txt | 372 +- compose/ui/ui/build.gradle | 2 +- .../compose/ui/demos/ScreenCoordinatesDemo.kt | 1 - .../java/androidx/compose/ui/demos/UiDemos.kt | 27 +- .../demos/autofill/AutofillNavigationDemo.kt | 195 + .../autofill/ExplicitAutofillTypesDemo.kt | 3 - .../demos/autofill/TextFieldAutofillDemo.kt | 241 ++ ...itEnterExitWithCustomFocusEnterExitDemo.kt | 21 +- .../ui/demos/focus/FocusRestorationDemo.kt | 9 +- .../ui/demos/focus/LazyListChildFocusDemos.kt | 5 +- compose/ui/ui/lint-baseline.xml | 33 +- compose/ui/ui/proguard-rules.pro | 5 + .../compose/ui/samples/FocusSamples.kt | 14 +- .../compose/ui/samples/PointerIconSample.kt | 37 + .../AndroidManifest.xml | 6 + .../compose/ui/AndroidAccessibilityTest.kt | 1 + .../ui/autofill/AndroidAutoFillTest.kt | 5 + ...rTest.kt => AndroidAutofillManagerTest.kt} | 92 +- ...t => PerformAndroidAutofillManagerTest.kt} | 78 +- .../TextFieldStateSemanticAutofillTest.kt | 157 + .../TextFieldsSemanticAutofillTest.kt | 256 ++ .../compose/ui/focus/ClearFocusExitTest.kt | 50 +- .../ui/focus/CustomFocusTraversalTest.kt | 12 +- .../compose/ui/focus/FocusRequesterTest.kt | 6 +- .../compose/ui/focus/FocusRestorerTest.kt | 2 +- .../compose/ui/focus/FocusTransactionsTest.kt | 3 +- .../compose/ui/focus/FocusViewInteropTest.kt | 6 +- .../ui/focus/RequestFocusEnterExitTest.kt | 33 +- .../compose/ui/focus/RequestFocusEnterTest.kt | 68 +- .../compose/ui/focus/RequestFocusExitTest.kt | 4 +- .../TwoDimensionalFocusTraversalEnterTest.kt | 7 +- .../TwoDimensionalFocusTraversalExitTest.kt | 48 +- ...ensionalFocusTraversalImplicitEnterTest.kt | 96 +- ...mensionalFocusTraversalImplicitExitTest.kt | 59 +- ...DimensionalFocusTraversalThreeItemsTest.kt | 1568 ++++--- .../nestedscroll/NestedScrollModifierTest.kt | 4 +- .../input/pointer/AndroidPointerInputTest.kt | 118 +- .../ui/input/pointer/HandwritingTestUtils.kt | 176 + .../ui/input/pointer/HitPathTrackerTest.kt | 13 +- .../ui/input/pointer/PointerIconTest.kt | 6 + .../pointer/PointerInputEventProcessorTest.kt | 14 +- .../ui/input/pointer/StylusHoverIconTest.kt | 3705 +++++++++++++++++ .../ui/input/rotary/RotaryScrollEventTest.kt | 37 + .../androidx/compose/ui/layout/Helpers.kt | 17 +- .../ui/layout/OnGlobalRectChangedTest.kt | 834 ++++ .../ui/layout/OnGloballyPositionedTest.kt | 2 +- .../compose/ui/layout/SubcomposeLayoutTest.kt | 9 + .../ModifierNodeReuseAndDeactivationTest.kt | 4 +- .../compose/ui/node/NodeChainTester.kt | 14 +- ...AndroidComposeViewScreenCoordinatesTest.kt | 1 - .../compose/ui/platform/ComposeViewTest.kt | 8 + .../compose/ui/platform/RecycledLayersTest.kt | 126 + .../WindowInfoCompositionLocalTest.kt | 92 +- .../compose/ui/res/ColorResourcesTest.kt | 28 +- .../compose/ui/res/ImageResourcesTest.kt | 58 + .../compose/ui/res/PrimitiveResourcesTest.kt | 93 +- .../compose/ui/semantics/SemanticsInfoTest.kt | 276 ++ .../ui/semantics/SemanticsListenerTest.kt | 556 +++ .../ui/semantics/SemanticsModifierNodeTest.kt | 211 + .../compose/ui/test/ConfigChangeActivity.kt | 41 + .../NullableInputConnectionWrapperTest.kt | 24 +- .../VelocityTrackingListParityTest.kt | 3 + .../androidx/compose/ui/window/DialogTest.kt | 188 +- .../compose/ui/window/PopupTestUtils.kt | 1 + .../drawable-night/test_image_day_night.png | Bin 0 -> 135 bytes .../res/drawable/test_image.png | Bin 0 -> 135 bytes .../res/drawable/test_image_day_night.png | Bin 0 -> 135 bytes .../res/values-night/resources.xml | 27 + .../res/values/donottranslate-strings.xml | 13 - .../res/values/resources.xml | 24 + .../androidx/compose/ui/Actual.android.kt | 33 + ...d.kt => AndroidAutofillManager.android.kt} | 98 +- .../ui/autofill/ContentDataType.android.kt | 18 +- .../ui/autofill/ContentType.android.kt | 48 +- .../PlatformHapticFeedback.android.kt | 36 + .../ui/input/pointer/PointerEvent.android.kt | 5 +- .../ui/layout/LayoutCoordinates.android.kt | 54 - .../ui/platform/AndroidComposeView.android.kt | 149 +- ...ViewAccessibilityDelegateCompat.android.kt | 736 ++-- .../AndroidCompositionLocals.android.kt | 9 + .../ui/platform/AndroidTextToolbar.android.kt | 21 +- .../ui/platform/AndroidWindowInfo.android.kt | 391 ++ .../ui/platform/ComposeView.android.kt | 1 + .../GraphicsLayerOwnerLayer.android.kt | 78 +- .../ui/platform/HapticFeedback.android.kt | 97 + .../ui/platform/LayerMatrixCache.android.kt | 11 +- .../compose/ui/platform/ViewLayer.android.kt | 1 + .../TextActionModeCallback.android.kt | 19 +- .../compose/ui/res/ColorResources.android.kt | 17 +- .../compose/ui/res/ImageResources.android.kt | 7 +- .../ui/res/PrimitiveResources.android.kt | 13 +- .../NullableInputConnectionWrapper.android.kt | 6 +- .../ui/viewinterop/FocusGroupNode.android.kt | 117 +- .../ui/window/AndroidDialog.android.kt | 331 +- .../ui/src/androidMain/res/values/strings.xml | 2 + .../ui/autofill/AndroidContentTypeTest.kt | 117 +- .../node/HitTestTouchBoundsExpansionTest.kt | 411 ++ .../compose/ui/node/LayoutNodeTest.kt | 66 +- .../node/ModifierLocalConsumerEntityTest.kt | 17 +- .../ui/node/TouchBoundsExpansionTest.kt | 98 + .../compose/ui/spatial/RectListTest.kt | 15 + .../ui/spatial/ThrottledCallbacksTest.kt | 245 ++ .../ui/text/TextActionModeCallbackTest.kt | 48 + .../androidx/compose/ui/ComposeUiFlags.kt | 17 + .../kotlin/androidx/compose/ui/Expect.kt | 6 + .../androidx/compose/ui/autofill/Autofill.kt | 5 +- .../compose/ui/autofill/AutofillManager.kt | 54 + .../compose/ui/autofill/AutofillType.kt | 2 +- .../compose/ui/autofill/ContentDataType.kt | 30 +- .../compose/ui/autofill/ContentType.kt | 13 +- .../androidx/compose/ui/focus/FocusOwner.kt | 5 +- .../compose/ui/focus/FocusOwnerImpl.kt | 21 +- .../compose/ui/focus/FocusProperties.kt | 94 +- .../compose/ui/focus/FocusRequester.kt | 3 + .../compose/ui/focus/FocusRestorer.kt | 50 +- .../ui/focus/FocusTargetModifierNode.kt | 17 +- .../compose/ui/focus/FocusTargetNode.kt | 34 +- .../ui/focus/FocusTransactionManager.kt | 10 + .../compose/ui/focus/FocusTransactions.kt | 7 +- .../compose/ui/focus/FocusTraversal.kt | 25 +- .../compose/ui/graphics/vector/ImageVector.kt | 7 +- .../ui/hapticfeedback/HapticFeedbackType.kt | 89 +- .../nestedscroll/NestedScrollModifier.kt | 14 +- .../ui/input/nestedscroll/NestedScrollNode.kt | 28 +- .../compose/ui/input/pointer/PointerIcon.kt | 177 +- .../pointer/PointerInputEventProcessor.kt | 3 +- .../pointer/SuspendingPointerInputFilter.kt | 11 +- .../compose/ui/layout/LayoutCoordinates.kt | 18 +- .../ui/layout/LookaheadLayoutCoordinates.kt | 4 + .../ui/layout/OnRectChangedModifier.kt | 143 + .../compose/ui/layout/SubcomposeLayout.kt | 58 +- .../compose/ui/node/DelegatableNode.kt | 4 +- .../androidx/compose/ui/node/HitTestResult.kt | 206 +- .../compose/ui/node/InnerNodeCoordinator.kt | 7 +- .../androidx/compose/ui/node/LayoutNode.kt | 163 +- .../ui/node/MeasureAndLayoutDelegate.kt | 6 + .../androidx/compose/ui/node/NodeChain.kt | 66 +- .../compose/ui/node/NodeCoordinator.kt | 243 +- .../kotlin/androidx/compose/ui/node/Owner.kt | 13 +- .../ui/node/PointerInputModifierNode.kt | 12 + .../compose/ui/node/SemanticsModifierNode.kt | 9 +- .../compose/ui/node/TouchBoundsExpansion.kt | 247 ++ .../androidx/compose/ui/node/WeakReference.kt | 3 +- .../compose/ui/platform/CompositionLocals.kt | 10 + .../compose/ui/platform/Synchronization.kt | 15 +- .../compose/ui/platform/TextToolbar.kt | 41 +- .../compose/ui/platform/WindowInfo.kt | 45 +- .../compose/ui/semantics/SemanticsInfo.kt | 85 + .../compose/ui/semantics/SemanticsListener.kt | 34 + .../compose/ui/semantics/SemanticsNode.kt | 12 +- .../compose/ui/semantics/SemanticsOwner.kt | 22 +- .../ui/semantics/SemanticsProperties.kt | 18 +- .../androidx/compose/ui/spatial/RectInfo.kt | 135 + .../androidx/compose/ui/spatial/RectList.kt | 108 +- .../compose/ui/spatial/RectManager.kt | 125 +- .../compose/ui/spatial/ThrottledCallbacks.kt | 309 ++ .../PlatformHapticFeedbackType.desktop.kt | 25 - .../androidx/compose/ui/Actuals.jsNative.kt | 4 + .../androidx/compose/ui/Actuals.jsWasm.kt | 5 + .../compose/ui/node/WeakReference.jsWasm.kt | 2 +- ...Actuals.jsWasm.kt => DebugUtils.jsWasm.kt} | 0 .../kotlin/androidx/compose/ui/Actual.jvm.kt | 2 + .../compose/ui/node/WeakReference.jvm.kt | 3 +- ...ctuals.nativeMain.kt => Actuals.native.kt} | 7 +- .../compose/ui/node/WeakReference.native.kt | 4 +- .../androidx/compose/ui/Actuals.skiko.kt | 38 + .../ui/autofill/ContentDataType.skiko.kt | 25 +- .../compose/ui/autofill/ContentType.skiko.kt | 288 +- .../HapticFeedbackType.skiko.kt | 33 + .../compose/ui/node/RootNodeOwner.skiko.kt | 30 +- .../node/SnapshotInvalidationTracker.skiko.kt | 10 +- .../FlushCoroutineDispatcher.skiko.kt | 6 +- .../ui/platform/Synchronization.skiko.kt | 35 + .../compose/ui/platform/WindowInfo.skiko.kt | 66 - .../accessibility/LayersAccessibilityTest.kt | 179 - .../compose/ui/test/AccessibilityTestNode.kt | 311 -- .../compose/ui/test/UIKitInstrumentedTest.kt | 7 - .../ui/platform/Accessibility.uikit.kt | 117 +- .../ui/platform/IOSSkikoInput.uikit.kt | 30 +- .../platform/UIKitTextInputService.uikit.kt | 32 +- .../ComposeHostingViewController.uikit.kt | 45 +- .../ui/scene/ComposeSceneMediator.uikit.kt | 62 +- .../ui/scene/UIKitComposeSceneLayer.uikit.kt | 23 +- .../UIKitComposeSceneLayersHolder.uikit.kt | 30 +- .../IntermediateTextInputUIView.uikit.kt | 25 +- 803 files changed, 37361 insertions(+), 12454 deletions(-) create mode 100644 compose/animation/animation-core/proguard-rules.pro create mode 100644 compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LazyStaggeredGridWithLookahead.kt create mode 100644 compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyGridDemo.kt create mode 100644 compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/LazyGridSharedElementDemo.kt create mode 100644 compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/SharedElementInLazyStaggeredGridDemo.kt delete mode 100644 compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/InsetsHelper.kt delete mode 100644 compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/InsetsHelper.android.kt delete mode 100644 compose/foundation/foundation-layout/src/androidUnitTest/kotlin/androidx/compose/foundation/layout/InsetsHelperTest.kt create mode 100644 compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ExpandedTouchBoundsDemo.kt create mode 100644 compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridInLookaheadTest.kt create mode 100644 compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt delete mode 100644 compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableTestState.kt create mode 100644 compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldHandwritingBoundsTest.kt create mode 100644 compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingHoverIconTest.kt delete mode 100644 compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/BackspaceCommandTest.kt create mode 100644 compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/test/IntRectSubject.kt create mode 100644 compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt rename compose/foundation/foundation/src/{commonMain/kotlin/androidx/compose/foundation/text/input/internal/EditCommand.kt => androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt} (69%) create mode 100644 compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommandTest.kt create mode 100644 compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollDeltaBetweenPasses.kt rename compose/{ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.js.kt => foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/platform/Synchronization.kt} (56%) create mode 100644 compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt create mode 100644 compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTextLayoutScope.kt rename compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/{DesktopOverscroll.kt => Overscroll.desktop.kt} (68%) rename compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/{JsOverScroll.kt => Overscroll.jsWasm.kt} (85%) create mode 100644 compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/platform/Synchronization.skiko.kt create mode 100644 compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.skiko.kt create mode 100644 compose/integration-tests/hero/README.md rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/Snack.kt (96%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/SnackCollection.kt (93%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/Card.kt (91%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/DestinationBar.kt (90%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/Divider.kt (92%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/Feed.kt (93%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/Snacks.kt (96%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/Surface.kt (96%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/theme/Color.kt (97%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/theme/Shape.kt (92%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/theme/Theme.kt (99%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/compose/theme/Type.kt (96%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/views/DessertAdapter.kt (95%) rename compose/integration-tests/hero/{hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack => jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation}/views/FeedAdapter.kt (89%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/drawable-nodpi/donut.jpeg (100%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/drawable-nodpi/eclair.jpeg (100%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/drawable/arrow_forward_24.xml (100%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/font/karla_regular.ttf (100%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/layout/item_snack_card_view.xml (100%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/layout/item_snack_view.xml (100%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/layout/snack_feed.xml (100%) rename compose/integration-tests/hero/{hero-implementation => jetsnack/jetsnack-implementation}/src/main/res/values/jetsnack_strings.xml (100%) rename compose/integration-tests/hero/{macrobenchmark-target => jetsnack/jetsnack-macrobenchmark-target}/src/main/AndroidManifest.xml (89%) rename compose/integration-tests/hero/{macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target => jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark}/IdleTracking.kt (92%) rename compose/integration-tests/hero/{macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack => jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target}/JetsnackActivity.kt (74%) rename compose/integration-tests/hero/{macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack => jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target}/JetsnackViewActivity.kt (85%) rename compose/integration-tests/hero/{macrobenchmark-target => jetsnack/jetsnack-macrobenchmark-target}/src/main/res/drawable/ic_launcher_foreground.xml (100%) rename compose/integration-tests/hero/{macrobenchmark-target => jetsnack/jetsnack-macrobenchmark-target}/src/main/res/layout/activity_jetsnack_view.xml (100%) rename compose/integration-tests/hero/{macrobenchmark-target => jetsnack/jetsnack-macrobenchmark-target}/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename compose/integration-tests/hero/{macrobenchmark-target => jetsnack/jetsnack-macrobenchmark-target}/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename compose/integration-tests/hero/{macrobenchmark-target => jetsnack/jetsnack-macrobenchmark-target}/src/main/res/values/ic_launcher_background.xml (100%) rename compose/integration-tests/hero/{macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark => jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack}/Utils.kt (86%) rename compose/{ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Expect.jsNative.kt => integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackConstants.kt} (77%) rename compose/integration-tests/hero/{macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack => jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark}/JetsnackFocusBenchmark.kt (88%) rename compose/integration-tests/hero/{macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack => jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark}/JetsnackScrollBenchmark.kt (87%) rename compose/integration-tests/hero/{macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack => jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark}/JetsnackStartupBenchmark.kt (82%) rename compose/integration-tests/hero/{benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack => jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark}/JetsnackBenchmark.kt (94%) rename compose/integration-tests/hero/{benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack => jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark}/JetsnackCaseFactory.kt (82%) create mode 100644 compose/material/material/api/current.ignore create mode 100644 compose/material/material/api/restricted_current.ignore create mode 100644 compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/SlideUsingKeysTest.kt create mode 100644 compose/material/material/src/androidMain/res/values-af/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-am/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ar/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-as/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-az/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-b+sr+Latn/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-be/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-bg/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-bn/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-bs/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ca/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-cs/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-da/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-de/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-el/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-en-rAU/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-en-rCA/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-en-rGB/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-en-rIN/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-en-rXC/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-es-rUS/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-es/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-et/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-eu/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-fa/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-fi/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-fr-rCA/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-fr/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-gl/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-gu/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-hi/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-hr/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-hu/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-hy/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-in/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-is/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-it/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-iw/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ja/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ka/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-kk/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-km/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-kn/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ko/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ky/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-lo/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-lt/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-lv/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-mk/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ml/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-mn/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-mr/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ms/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-my/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-nb/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ne/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-nl/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-or/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-pa/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-pl/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-pt-rBR/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-pt-rPT/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-pt/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ro/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ru/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-si/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-sk/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-sl/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-sq/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-sr/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-sv/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-sw/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ta/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-te/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-th/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-tl/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-tr/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-uk/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-ur/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-uz/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-vi/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-zh-rCN/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-zh-rHK/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-zh-rTW/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values-zu/strings.xml create mode 100644 compose/material/material/src/androidMain/res/values/public.xml create mode 100644 compose/material/material/src/androidMain/res/values/strings.xml create mode 100644 compose/material/material/src/commonMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.kt create mode 100644 compose/material/material/src/skikoMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.skiko.kt rename compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/{ => tooling}/AndroidCompositionObserverTests.kt (84%) create mode 100644 compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/tooling/CompositionRegistrationObserverTest.kt create mode 100644 compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeRuntimeFlags.kt create mode 100644 compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/Extensions.kt create mode 100644 compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MultiValueMap.kt rename compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/{SynchronizedObject.kt => platform/Synchronization.kt} (64%) create mode 100644 compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt rename compose/runtime/runtime/src/darwinMain/kotlin/runtime/{SynchronizedObject.darwin.kt => platform/Synchronization.darwin.kt} (94%) rename compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/{IdentityHashCode.jvm.kt => ActualJvm.jvm.kt} (67%) rename compose/runtime/runtime/src/{jvmMain/kotlin/androidx/compose/runtime/SynchronizedObject.jvm.kt => desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt} (69%) create mode 100644 compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt create mode 100644 compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt rename compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/{SynchronizedObject.linux.kt => platform/Synchronization.linux.kt} (94%) rename compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/{SynchronizedObject.mingwX64.kt => platform/Synchronization.mingwX64.kt} (92%) create mode 100644 compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt rename compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/{ => platform}/SynchronizationTest.kt (98%) rename compose/runtime/runtime/src/posixMain/kotlin/androidx/compose/runtime/{SynchronizedObject.posix.kt => platform/Synchronization.posix.kt} (97%) create mode 100644 compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt rename compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/{ActualJsWasm.web.kt => WeakReference.web.kt} (100%) rename compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/{internal/SynchronizedObject.web.kt => platform/Synchronization.web.kt} (84%) create mode 100644 compose/ui/ui-geometry/api/current.ignore create mode 100644 compose/ui/ui-geometry/api/restricted_current.ignore create mode 100644 compose/ui/ui-graphics/proguard-rules.pro create mode 100644 compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt create mode 100644 compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt rename compose/ui/{ui/src/nativeMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.native.kt => ui-test/src/commonMain/kotlin/androidx/compose/ui/test/platform/Synchronization.kt} (56%) delete mode 100644 compose/ui/ui-test/src/jsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jsMain.kt delete mode 100644 compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jvm.kt delete mode 100644 compose/ui/ui-test/src/nativeMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.nativeMain.kt rename compose/ui/{ui/src/commonMain/kotlin/androidx/compose/ui/autofill/SemanticAutofill.kt => ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/platform/Synchronization.skiko.kt} (50%) delete mode 100644 compose/ui/ui-test/src/wasmJsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.wasmMain.kt create mode 100644 compose/ui/ui-text/proguard-rules.pro delete mode 100644 compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/SegmentBreakerTest.kt delete mode 100644 compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/animation/SegmentBreakerBreakSegmentTest.kt delete mode 100644 compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentBreaker.android.kt delete mode 100644 compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentType.android.kt create mode 100644 compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/internal/InlineClassHelper.kt create mode 100644 compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/Synchronization.skiko.kt create mode 100644 compose/ui/ui-unit/proguard-rules.pro create mode 100644 compose/ui/ui-util/proguard-rules.pro create mode 100644 compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt create mode 100644 compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/TextFieldAutofillDemo.kt rename compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/{SemanticAutofillManagerTest.kt => AndroidAutofillManagerTest.kt} (72%) rename compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/{AndroidSemanticAutofillTest.kt => PerformAndroidAutofillManagerTest.kt} (93%) create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/RecycledLayersTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ImageResourcesTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ConfigChangeActivity.kt create mode 100644 compose/ui/ui/src/androidInstrumentedTest/res/drawable-night/test_image_day_night.png create mode 100644 compose/ui/ui/src/androidInstrumentedTest/res/drawable/test_image.png create mode 100644 compose/ui/ui/src/androidInstrumentedTest/res/drawable/test_image_day_night.png create mode 100644 compose/ui/ui/src/androidInstrumentedTest/res/values-night/resources.xml create mode 100644 compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/Actual.android.kt rename compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/{AndroidSemanticAutofill.android.kt => AndroidAutofillManager.android.kt} (88%) delete mode 100644 compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.android.kt create mode 100644 compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidWindowInfo.android.kt create mode 100644 compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/HapticFeedback.android.kt create mode 100644 compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/HitTestTouchBoundsExpansionTest.kt create mode 100644 compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/TouchBoundsExpansionTest.kt create mode 100644 compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt create mode 100644 compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt create mode 100644 compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnRectChangedModifier.kt create mode 100644 compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt create mode 100644 compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt create mode 100644 compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt create mode 100644 compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectInfo.kt create mode 100644 compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt delete mode 100644 compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.desktop.kt rename compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/{Actuals.jsWasm.kt => DebugUtils.jsWasm.kt} (100%) rename compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/{Actuals.nativeMain.kt => Actuals.native.kt} (84%) create mode 100644 compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt create mode 100644 compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.skiko.kt create mode 100644 compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/Synchronization.skiko.kt delete mode 100644 compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/WindowInfo.skiko.kt delete mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/LayersAccessibilityTest.kt delete mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt diff --git a/compose/OWNERS b/compose/OWNERS index 65581bf2c0b6c..89d5edf3ba374 100644 --- a/compose/OWNERS +++ b/compose/OWNERS @@ -1,6 +1,5 @@ # Bug component: 612128 -chuckj@google.com -jsproch@google.com lelandr@google.com -malkov@google.com clarabayarri@google.com +alanv@google.com +adamp@google.com \ No newline at end of file diff --git a/compose/animation/animation-core/api/current.ignore b/compose/animation/animation-core/api/current.ignore index c707b516d71f3..072966f6fe282 100644 --- a/compose/animation/animation-core/api/current.ignore +++ b/compose/animation/animation-core/api/current.ignore @@ -1,9 +1,5 @@ // Baseline format: 1.0 -ChangedType: androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig#at(T, int): - Method androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig.at has changed return type from androidx.compose.animation.core.KeyframesSpec.KeyframeEntity to androidx.compose.animation.core.KeyframesSpec.KeyframeEntity -ChangedType: androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig#atFraction(T, float): - Method androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig.atFraction has changed return type from androidx.compose.animation.core.KeyframesSpec.KeyframeEntity to androidx.compose.animation.core.KeyframesSpec.KeyframeEntity - - -RemovedClass: androidx.compose.animation.core.SpringEstimationKt: - Removed class androidx.compose.animation.core.SpringEstimationKt +RemovedMethod: androidx.compose.animation.core.StartOffset#getOffsetMillis(): + Removed method androidx.compose.animation.core.StartOffset.getOffsetMillis() +RemovedMethod: androidx.compose.animation.core.StartOffset#getOffsetType(): + Removed method androidx.compose.animation.core.StartOffset.getOffsetType() diff --git a/compose/animation/animation-core/api/current.txt b/compose/animation/animation-core/api/current.txt index b19fbe0465677..54a831a253aa9 100644 --- a/compose/animation/animation-core/api/current.txt +++ b/compose/animation/animation-core/api/current.txt @@ -70,6 +70,8 @@ package androidx.compose.animation.core { } public final class AnimationConstants { + property public static final int DefaultDurationMillis; + property public static final long UnspecifiedTime; field public static final int DefaultDurationMillis = 300; // 0x12c field public static final androidx.compose.animation.core.AnimationConstants INSTANCE; field public static final long UnspecifiedTime = -9223372036854775808L; // 0x8000000000000000L @@ -585,6 +587,16 @@ package androidx.compose.animation.core { } public final class Spring { + property public static final float DampingRatioHighBouncy; + property public static final float DampingRatioLowBouncy; + property public static final float DampingRatioMediumBouncy; + property public static final float DampingRatioNoBouncy; + property public static final float DefaultDisplacementThreshold; + property public static final float StiffnessHigh; + property public static final float StiffnessLow; + property public static final float StiffnessMedium; + property public static final float StiffnessMediumLow; + property public static final float StiffnessVeryLow; field public static final float DampingRatioHighBouncy = 0.2f; field public static final float DampingRatioLowBouncy = 0.75f; field public static final float DampingRatioMediumBouncy = 0.5f; @@ -612,8 +624,6 @@ package androidx.compose.animation.core { @kotlin.jvm.JvmInline public final value class StartOffset { ctor public StartOffset(int offsetMillis, optional int offsetType); - method public int getOffsetMillis(); - method public int getOffsetType(); property public final int offsetMillis; property public final int offsetType; } diff --git a/compose/animation/animation-core/api/restricted_current.ignore b/compose/animation/animation-core/api/restricted_current.ignore index c707b516d71f3..072966f6fe282 100644 --- a/compose/animation/animation-core/api/restricted_current.ignore +++ b/compose/animation/animation-core/api/restricted_current.ignore @@ -1,9 +1,5 @@ // Baseline format: 1.0 -ChangedType: androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig#at(T, int): - Method androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig.at has changed return type from androidx.compose.animation.core.KeyframesSpec.KeyframeEntity to androidx.compose.animation.core.KeyframesSpec.KeyframeEntity -ChangedType: androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig#atFraction(T, float): - Method androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig.atFraction has changed return type from androidx.compose.animation.core.KeyframesSpec.KeyframeEntity to androidx.compose.animation.core.KeyframesSpec.KeyframeEntity - - -RemovedClass: androidx.compose.animation.core.SpringEstimationKt: - Removed class androidx.compose.animation.core.SpringEstimationKt +RemovedMethod: androidx.compose.animation.core.StartOffset#getOffsetMillis(): + Removed method androidx.compose.animation.core.StartOffset.getOffsetMillis() +RemovedMethod: androidx.compose.animation.core.StartOffset#getOffsetType(): + Removed method androidx.compose.animation.core.StartOffset.getOffsetType() diff --git a/compose/animation/animation-core/api/restricted_current.txt b/compose/animation/animation-core/api/restricted_current.txt index 99d7a3797ee9d..a482411c228ae 100644 --- a/compose/animation/animation-core/api/restricted_current.txt +++ b/compose/animation/animation-core/api/restricted_current.txt @@ -70,6 +70,8 @@ package androidx.compose.animation.core { } public final class AnimationConstants { + property public static final int DefaultDurationMillis; + property public static final long UnspecifiedTime; field public static final int DefaultDurationMillis = 300; // 0x12c field public static final androidx.compose.animation.core.AnimationConstants INSTANCE; field public static final long UnspecifiedTime = -9223372036854775808L; // 0x8000000000000000L @@ -585,6 +587,16 @@ package androidx.compose.animation.core { } public final class Spring { + property public static final float DampingRatioHighBouncy; + property public static final float DampingRatioLowBouncy; + property public static final float DampingRatioMediumBouncy; + property public static final float DampingRatioNoBouncy; + property public static final float DefaultDisplacementThreshold; + property public static final float StiffnessHigh; + property public static final float StiffnessLow; + property public static final float StiffnessMedium; + property public static final float StiffnessMediumLow; + property public static final float StiffnessVeryLow; field public static final float DampingRatioHighBouncy = 0.2f; field public static final float DampingRatioLowBouncy = 0.75f; field public static final float DampingRatioMediumBouncy = 0.5f; @@ -612,8 +624,6 @@ package androidx.compose.animation.core { @kotlin.jvm.JvmInline public final value class StartOffset { ctor public StartOffset(int offsetMillis, optional int offsetType); - method public int getOffsetMillis(); - method public int getOffsetType(); property public final int offsetMillis; property public final int offsetType; } diff --git a/compose/animation/animation-core/proguard-rules.pro b/compose/animation/animation-core/proguard-rules.pro new file mode 100644 index 0000000000000..67d118b98fa5e --- /dev/null +++ b/compose/animation/animation-core/proguard-rules.pro @@ -0,0 +1,24 @@ +# Copyright (C) 2020 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. + +# Keep all the functions created to throw an exception. We don't want these functions to be +# inlined in any way, which R8 will do by default. The whole point of these functions is to +# reduce the amount of code generated at the call site. +-keep,allowshrinking,allowobfuscation class androidx.compose.**.* { + static void throw*Exception(...); + static void throw*ExceptionForNullCheck(...); + # For methods returning Nothing + static java.lang.Void throw*Exception(...); + static java.lang.Void throw*ExceptionForNullCheck(...); +} diff --git a/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/EasingTest.android.kt b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/EasingTest.android.kt index 4b5a229c77128..05381b527b986 100644 --- a/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/EasingTest.android.kt +++ b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/EasingTest.android.kt @@ -17,6 +17,7 @@ package androidx.compose.animation.core import androidx.compose.ui.util.floatFromBits +import kotlin.math.max import kotlin.math.ulp import kotlin.test.Test import kotlin.test.assertTrue @@ -24,11 +25,11 @@ import kotlin.test.assertTrue // This test can't be in commonTest because // Float.ulp is jvm only: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.math/ulp.html class EasingTestAndroid { - private val ZeroEpsilon = -(1.0f.ulp * 2.0f) + private val ZeroEpsilon = 1.0f.ulp * 2.0f private val OneEpsilon = 1.0f + 1.0f.ulp * 2.0f @Test - fun canSolveCubicForFractionsCloseToOne() { + fun canSolveCubicForFractionsCloseToZeroOrOne() { // Only test curves defined in [0..1] // For instance, EaseInOutBack is defined in a larger domain, so exclude it from the list val curves = @@ -53,6 +54,9 @@ class EasingTestAndroid { EaseInOutQuint, EaseInSine, EaseOut, + // Not included because it overshoots 1.0f on purpose, so it can't be tested the + // same way as the other curves. See canSolveOvershootingCurve() + // EaseOutBack, EaseOutCirc, EaseOutCubic, EaseOutExpo, @@ -63,8 +67,7 @@ class EasingTestAndroid { ) for (curve in curves) { - // Test the last 16 ulps until 1.0f - for (i in 0x3f7ffff0..0x3f7fffff) { + for (i in 0x3f7f9d99..0x3f7fffff) { val fraction = floatFromBits(i) val t = curve.transform(fraction) assertTrue( @@ -72,6 +75,34 @@ class EasingTestAndroid { "f($fraction) = $t out of range for $curve | ${-ZeroEpsilon}..${OneEpsilon}" ) } + + for (i in 0x0..0x6266) { + val fraction = floatFromBits(i) + val t = curve.transform(fraction) + assertTrue( + t in -ZeroEpsilon..OneEpsilon, + "f($fraction) = $t out of range for $curve | ${-ZeroEpsilon}..${OneEpsilon}" + ) + } + + // Test at 1.5058824E-7, small value but not too close to 0.0 either + val fraction = floatFromBits(0x3421b161) + val t = curve.transform(fraction) + assertTrue( + t in -ZeroEpsilon..OneEpsilon, + "f($fraction) = $t out of range for $curve | ${-ZeroEpsilon}..${OneEpsilon}" + ) + } + } + + @Test + fun canSolveOvershootingCurve() { + // We only really care that we don't throw an exception + var t = Float.MIN_VALUE + for (i in 0x3f7f9d99..0x3f7fffff) { + val fraction = floatFromBits(i) + t = max(t, EaseOutBack.transform(fraction)) } + assertTrue(t > Float.MIN_VALUE) } } diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt index 9ff5f0f75b07a..80bdfb920b438 100644 --- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt +++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.computeCubicVerticalBounds import androidx.compose.ui.graphics.evaluateCubic import androidx.compose.ui.graphics.findFirstCubicRoot import androidx.compose.ui.util.fastCoerceIn +import kotlin.math.max /** * Easing is a way to adjust an animation’s fraction. Easing allows transitioning elements to speed @@ -70,6 +71,10 @@ public val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1 */ public val LinearEasing: Easing = Easing { fraction -> fraction } +// This is equal to 1f.ulp or 1f.nextUp() - 1f, but neither ulp nor nextUp() are part of all KMP +// targets, only JVM and native +private const val OneUlpAt1 = 1.1920929e-7f + /** * A cubic polynomial easing. * @@ -125,12 +130,16 @@ public class CubicBezierEasing( */ override fun transform(fraction: Float): Float { return if (fraction > 0f && fraction < 1f) { + // We translate the coordinates by the fraction when calling findFirstCubicRoot, + // but we need to make sure the translation can be done at 1.0f so we take at + // least 1 ulp at 1.0f + val f = max(fraction, OneUlpAt1) val t = findFirstCubicRoot( - 0.0f - fraction, - a - fraction, - c - fraction, - 1.0f - fraction, + 0.0f - f, + a - f, + c - f, + 1.0f - f, ) // No root, the cubic curve has no solution diff --git a/compose/animation/animation-graphics/api/current.txt b/compose/animation/animation-graphics/api/current.txt index a2ccbd2882e8d..088d0be0e4890 100644 --- a/compose/animation/animation-graphics/api/current.txt +++ b/compose/animation/animation-graphics/api/current.txt @@ -9,18 +9,18 @@ package androidx.compose.animation.graphics { package androidx.compose.animation.graphics.res { public final class AnimatedVectorPainterResources_androidKt { - method @SuppressCompatibility @androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi @androidx.compose.runtime.Composable public static androidx.compose.ui.graphics.painter.Painter rememberAnimatedVectorPainter(androidx.compose.animation.graphics.vector.AnimatedImageVector animatedImageVector, boolean atEnd); + method @androidx.compose.runtime.Composable public static androidx.compose.ui.graphics.painter.Painter rememberAnimatedVectorPainter(androidx.compose.animation.graphics.vector.AnimatedImageVector animatedImageVector, boolean atEnd); } public final class AnimatedVectorResources_androidKt { - method @SuppressCompatibility @androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi @androidx.compose.runtime.Composable public static androidx.compose.animation.graphics.vector.AnimatedImageVector animatedVectorResource(androidx.compose.animation.graphics.vector.AnimatedImageVector.Companion, @DrawableRes int id); + method @androidx.compose.runtime.Composable public static androidx.compose.animation.graphics.vector.AnimatedImageVector animatedVectorResource(androidx.compose.animation.graphics.vector.AnimatedImageVector.Companion, @DrawableRes int id); } } package androidx.compose.animation.graphics.vector { - @SuppressCompatibility @androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi @androidx.compose.runtime.Immutable public final class AnimatedImageVector { + @androidx.compose.runtime.Immutable public final class AnimatedImageVector { method public androidx.compose.ui.graphics.vector.ImageVector getImageVector(); method public int getTotalDuration(); property public final androidx.compose.ui.graphics.vector.ImageVector imageVector; diff --git a/compose/animation/animation-graphics/api/restricted_current.txt b/compose/animation/animation-graphics/api/restricted_current.txt index a2ccbd2882e8d..088d0be0e4890 100644 --- a/compose/animation/animation-graphics/api/restricted_current.txt +++ b/compose/animation/animation-graphics/api/restricted_current.txt @@ -9,18 +9,18 @@ package androidx.compose.animation.graphics { package androidx.compose.animation.graphics.res { public final class AnimatedVectorPainterResources_androidKt { - method @SuppressCompatibility @androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi @androidx.compose.runtime.Composable public static androidx.compose.ui.graphics.painter.Painter rememberAnimatedVectorPainter(androidx.compose.animation.graphics.vector.AnimatedImageVector animatedImageVector, boolean atEnd); + method @androidx.compose.runtime.Composable public static androidx.compose.ui.graphics.painter.Painter rememberAnimatedVectorPainter(androidx.compose.animation.graphics.vector.AnimatedImageVector animatedImageVector, boolean atEnd); } public final class AnimatedVectorResources_androidKt { - method @SuppressCompatibility @androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi @androidx.compose.runtime.Composable public static androidx.compose.animation.graphics.vector.AnimatedImageVector animatedVectorResource(androidx.compose.animation.graphics.vector.AnimatedImageVector.Companion, @DrawableRes int id); + method @androidx.compose.runtime.Composable public static androidx.compose.animation.graphics.vector.AnimatedImageVector animatedVectorResource(androidx.compose.animation.graphics.vector.AnimatedImageVector.Companion, @DrawableRes int id); } } package androidx.compose.animation.graphics.vector { - @SuppressCompatibility @androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi @androidx.compose.runtime.Immutable public final class AnimatedImageVector { + @androidx.compose.runtime.Immutable public final class AnimatedImageVector { method public androidx.compose.ui.graphics.vector.ImageVector getImageVector(); method public int getTotalDuration(); property public final androidx.compose.ui.graphics.vector.ImageVector imageVector; diff --git a/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorPainterResources.android.kt b/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorPainterResources.android.kt index 10476cdd55a99..71710d60e2db1 100644 --- a/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorPainterResources.android.kt +++ b/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorPainterResources.android.kt @@ -18,7 +18,6 @@ package androidx.compose.animation.graphics.res import androidx.annotation.VisibleForTesting import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.animation.graphics.vector.StateVectorConfig import androidx.compose.runtime.Composable @@ -38,7 +37,6 @@ import androidx.compose.ui.util.fastForEach * @param atEnd Whether the animated vector should be rendered at the end of all its animations. * @sample androidx.compose.animation.graphics.samples.AnimatedVectorSample */ -@ExperimentalAnimationGraphicsApi @Composable public fun rememberAnimatedVectorPainter( animatedImageVector: AnimatedImageVector, @@ -49,7 +47,6 @@ public fun rememberAnimatedVectorPainter( } } -@ExperimentalAnimationGraphicsApi @VisibleForTesting @Composable internal fun rememberAnimatedVectorPainter( diff --git a/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorResources.android.kt b/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorResources.android.kt index 6d3dd01604b6d..019517638bc6e 100644 --- a/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorResources.android.kt +++ b/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/res/AnimatedVectorResources.android.kt @@ -19,7 +19,6 @@ package androidx.compose.animation.graphics.res import android.content.res.Resources import android.util.Xml import androidx.annotation.DrawableRes -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.animation.graphics.vector.compat.parseAnimatedImageVector import androidx.compose.animation.graphics.vector.compat.seekToStartTag @@ -35,7 +34,6 @@ import org.xmlpull.v1.XmlPullParserException * @return an animated vector drawable resource. * @sample androidx.compose.animation.graphics.samples.AnimatedVectorSample */ -@ExperimentalAnimationGraphicsApi @Composable public fun AnimatedImageVector.Companion.animatedVectorResource( @DrawableRes id: Int @@ -46,7 +44,6 @@ public fun AnimatedImageVector.Companion.animatedVectorResource( return remember(id) { loadAnimatedVectorResource(theme, res, id) } } -@ExperimentalAnimationGraphicsApi @Throws(XmlPullParserException::class) internal fun loadAnimatedVectorResource( theme: Resources.Theme? = null, diff --git a/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/vector/compat/XmlAnimatedVectorParser.android.kt b/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/vector/compat/XmlAnimatedVectorParser.android.kt index 10f8123de878d..9d14d2349082a 100644 --- a/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/vector/compat/XmlAnimatedVectorParser.android.kt +++ b/compose/animation/animation-graphics/src/androidMain/kotlin/androidx/compose/animation/graphics/vector/compat/XmlAnimatedVectorParser.android.kt @@ -18,7 +18,6 @@ package androidx.compose.animation.graphics.vector.compat import android.content.res.Resources import android.util.AttributeSet -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.res.loadAnimatorResource import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.animation.graphics.vector.AnimatedVectorTarget @@ -54,7 +53,6 @@ private fun parseAnimatedVectorTarget( } } -@ExperimentalAnimationGraphicsApi internal fun XmlPullParser.parseAnimatedImageVector( res: Resources, theme: Resources.Theme?, diff --git a/compose/animation/animation-graphics/src/commonMain/kotlin/androidx/compose/animation/graphics/vector/AnimatedImageVector.kt b/compose/animation/animation-graphics/src/commonMain/kotlin/androidx/compose/animation/graphics/vector/AnimatedImageVector.kt index b8d9d0213f3e8..97544e2aa7cc1 100644 --- a/compose/animation/animation-graphics/src/commonMain/kotlin/androidx/compose/animation/graphics/vector/AnimatedImageVector.kt +++ b/compose/animation/animation-graphics/src/commonMain/kotlin/androidx/compose/animation/graphics/vector/AnimatedImageVector.kt @@ -16,7 +16,6 @@ package androidx.compose.animation.graphics.vector -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.util.fastMaxBy @@ -29,7 +28,6 @@ import androidx.compose.ui.util.fastMaxBy * @param imageVector The [ImageVector] to be animated. This is represented with the * `android:drawable` parameter of an `` element. */ -@ExperimentalAnimationGraphicsApi @Immutable public class AnimatedImageVector internal constructor( diff --git a/compose/animation/animation/api/current.txt b/compose/animation/animation/api/current.txt index 05c53bded3472..317da61762a55 100644 --- a/compose/animation/animation/api/current.txt +++ b/compose/animation/animation/api/current.txt @@ -26,6 +26,7 @@ package androidx.compose.animation { method public androidx.compose.animation.EnterTransition slideIntoContainer(int towards, optional androidx.compose.animation.core.FiniteAnimationSpec animationSpec, optional kotlin.jvm.functions.Function1 initialOffset); method public androidx.compose.animation.ExitTransition slideOutOfContainer(int towards, optional androidx.compose.animation.core.FiniteAnimationSpec animationSpec, optional kotlin.jvm.functions.Function1 targetOffset); method public infix androidx.compose.animation.ContentTransform using(androidx.compose.animation.ContentTransform, androidx.compose.animation.SizeTransform? sizeTransform); + property public androidx.compose.animation.ExitTransition KeepUntilTransitionsFinished; property public abstract androidx.compose.ui.Alignment contentAlignment; } @@ -155,14 +156,14 @@ package androidx.compose.animation { method @Deprecated public default androidx.compose.animation.EnterTransition scaleInSharedContentToBounds(optional androidx.compose.ui.layout.ContentScale contentScale, optional androidx.compose.ui.Alignment alignment); method @Deprecated public default androidx.compose.animation.ExitTransition scaleOutSharedContentToBounds(optional androidx.compose.ui.layout.ContentScale contentScale, optional androidx.compose.ui.Alignment alignment); method public androidx.compose.ui.Modifier sharedBounds(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, androidx.compose.animation.AnimatedVisibilityScope animatedVisibilityScope, optional androidx.compose.animation.EnterTransition enter, optional androidx.compose.animation.ExitTransition exit, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.ResizeMode resizeMode, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); - method public androidx.compose.ui.Modifier sharedElement(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState state, androidx.compose.animation.AnimatedVisibilityScope animatedVisibilityScope, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); + method public androidx.compose.ui.Modifier sharedElement(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, androidx.compose.animation.AnimatedVisibilityScope animatedVisibilityScope, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); method public androidx.compose.ui.Modifier sharedElementWithCallerManagedVisibility(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, boolean visible, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); method public androidx.compose.ui.Modifier skipToLookaheadSize(androidx.compose.ui.Modifier); property public abstract boolean isTransitionActive; } public static interface SharedTransitionScope.OverlayClip { - method public androidx.compose.ui.graphics.Path? getClipPath(androidx.compose.animation.SharedTransitionScope.SharedContentState state, androidx.compose.ui.geometry.Rect bounds, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density); + method public androidx.compose.ui.graphics.Path? getClipPath(androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, androidx.compose.ui.geometry.Rect bounds, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density); } public static fun interface SharedTransitionScope.PlaceHolderSize { diff --git a/compose/animation/animation/api/restricted_current.txt b/compose/animation/animation/api/restricted_current.txt index 05c53bded3472..317da61762a55 100644 --- a/compose/animation/animation/api/restricted_current.txt +++ b/compose/animation/animation/api/restricted_current.txt @@ -26,6 +26,7 @@ package androidx.compose.animation { method public androidx.compose.animation.EnterTransition slideIntoContainer(int towards, optional androidx.compose.animation.core.FiniteAnimationSpec animationSpec, optional kotlin.jvm.functions.Function1 initialOffset); method public androidx.compose.animation.ExitTransition slideOutOfContainer(int towards, optional androidx.compose.animation.core.FiniteAnimationSpec animationSpec, optional kotlin.jvm.functions.Function1 targetOffset); method public infix androidx.compose.animation.ContentTransform using(androidx.compose.animation.ContentTransform, androidx.compose.animation.SizeTransform? sizeTransform); + property public androidx.compose.animation.ExitTransition KeepUntilTransitionsFinished; property public abstract androidx.compose.ui.Alignment contentAlignment; } @@ -155,14 +156,14 @@ package androidx.compose.animation { method @Deprecated public default androidx.compose.animation.EnterTransition scaleInSharedContentToBounds(optional androidx.compose.ui.layout.ContentScale contentScale, optional androidx.compose.ui.Alignment alignment); method @Deprecated public default androidx.compose.animation.ExitTransition scaleOutSharedContentToBounds(optional androidx.compose.ui.layout.ContentScale contentScale, optional androidx.compose.ui.Alignment alignment); method public androidx.compose.ui.Modifier sharedBounds(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, androidx.compose.animation.AnimatedVisibilityScope animatedVisibilityScope, optional androidx.compose.animation.EnterTransition enter, optional androidx.compose.animation.ExitTransition exit, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.ResizeMode resizeMode, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); - method public androidx.compose.ui.Modifier sharedElement(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState state, androidx.compose.animation.AnimatedVisibilityScope animatedVisibilityScope, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); + method public androidx.compose.ui.Modifier sharedElement(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, androidx.compose.animation.AnimatedVisibilityScope animatedVisibilityScope, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); method public androidx.compose.ui.Modifier sharedElementWithCallerManagedVisibility(androidx.compose.ui.Modifier, androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, boolean visible, optional androidx.compose.animation.BoundsTransform boundsTransform, optional androidx.compose.animation.SharedTransitionScope.PlaceHolderSize placeHolderSize, optional boolean renderInOverlayDuringTransition, optional float zIndexInOverlay, optional androidx.compose.animation.SharedTransitionScope.OverlayClip clipInOverlayDuringTransition); method public androidx.compose.ui.Modifier skipToLookaheadSize(androidx.compose.ui.Modifier); property public abstract boolean isTransitionActive; } public static interface SharedTransitionScope.OverlayClip { - method public androidx.compose.ui.graphics.Path? getClipPath(androidx.compose.animation.SharedTransitionScope.SharedContentState state, androidx.compose.ui.geometry.Rect bounds, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density); + method public androidx.compose.ui.graphics.Path? getClipPath(androidx.compose.animation.SharedTransitionScope.SharedContentState sharedContentState, androidx.compose.ui.geometry.Rect bounds, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density); } public static fun interface SharedTransitionScope.PlaceHolderSize { diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LazyStaggeredGridWithLookahead.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LazyStaggeredGridWithLookahead.kt new file mode 100644 index 0000000000000..1df742e4a4678 --- /dev/null +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LazyStaggeredGridWithLookahead.kt @@ -0,0 +1,119 @@ +/* + * 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. + */ + +package androidx.compose.animation.demos.lookahead + +import android.annotation.SuppressLint +import androidx.compose.animation.demos.layoutanimation.turquoiseColors +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.random.Random + +@Preview +@Composable +fun LazyStaggeredGridDemo() { + var enableLookahead by remember { mutableStateOf(true) } + if (enableLookahead) { + LookaheadScope { Content(enableLookahead) { enableLookahead = !enableLookahead } } + } else { + Content(enableLookahead) { enableLookahead = !enableLookahead } + } +} + +@SuppressLint("PrimitiveInCollection") +@Composable +fun Content(lookaheadEnabled: Boolean, onLookaheadToggled: () -> Unit) { + + val heights = remember { List(100) { (Random.nextInt(100) + 100).dp } } + val indices = remember { mutableStateOf(List(100) { it }) } + var count by remember { mutableIntStateOf(100) } + + Column(Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Button( + onClick = { indices.value = indices.value.toMutableList().apply { shuffle() } } + ) { + Text(text = "shuffle") + } + Button(onClick = onLookaheadToggled) { + Text(if (lookaheadEnabled) "Lookahead enabled" else "Lookahead disabled") + } + } + + val state = rememberLazyStaggeredGridState(initialFirstVisibleItemIndex = 29) + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(100.dp), + modifier = Modifier.fillMaxSize(), + state = state, + contentPadding = PaddingValues(vertical = 30.dp, horizontal = 20.dp), + horizontalArrangement = Arrangement.End, + verticalItemSpacing = 10.dp, + content = { + items( + count, + span = { + if (it % 10 == 0) StaggeredGridItemSpan.FullLine + else StaggeredGridItemSpan.SingleLane + }, + key = { it } + ) { + var expanded by remember { mutableStateOf(false) } + val index = indices.value[it % indices.value.size] + val color = colors[index % colors.size] + Box( + modifier = + Modifier.animateItem() + .height(if (!expanded) heights[index] else heights[index] * 2) + .background(color, RoundedCornerShape(5.dp)) + .clickable { expanded = !expanded } + ) { + Text("$it", modifier = Modifier.align(Alignment.Center), fontSize = 36.sp) + } + } + } + ) + } +} + +@SuppressLint("PrimitiveInCollection") private val colors = turquoiseColors diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyGridDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyGridDemo.kt new file mode 100644 index 0000000000000..0289e90f378d8 --- /dev/null +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyGridDemo.kt @@ -0,0 +1,163 @@ +/* + * 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. + */ + +package androidx.compose.animation.demos.lookahead + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.demos.gesture.pastelColors +import androidx.compose.animation.demos.layoutanimation.summerColors +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlin.random.Random +import kotlinx.coroutines.delay + +@Preview +@Composable +fun LookaheadWithLazyGridDemo() { + LookaheadScope { + var visible by remember { mutableStateOf(false) } + LazyVerticalGrid(columns = GridCells.Fixed(3), contentPadding = PaddingValues(5.dp)) { + items(100) { + Box( + Modifier.padding(5.dp) + .clickable { visible = !visible } + .background(summerColors[it % 4], RoundedCornerShape(10.dp)) + .padding(top = 40.dp, bottom = 40.dp) + .fillMaxWidth() + ) { + AnimatedVisibility(visible) { Box(Modifier.height(100.dp)) } + } + } + } + } +} + +@Preview +@Composable +fun LookaheadSmallerThanApproach() { + LookaheadScope { + LazyVerticalGrid( + GridCells.Fixed(2), + Modifier.layout { m, c -> + val constraints = if (isLookingAhead) c.copy(maxHeight = c.maxHeight - 100) else c + m.measure(constraints).run { layout(width, height) { place(0, 0) } } + } + ) { + items(20) { + Text( + "item + $it", + Modifier.background(summerColors[it % summerColors.size]) + .height(100.dp) + .fillMaxWidth() + ) + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@SuppressLint("PrimitiveInCollection") +@Preview +@Composable +fun ShuffleLazyGridWithItemAnimationAndLookaheadAnimation() { + var list by remember { + mutableStateOf>(mutableListOf().apply { repeat(20) { add(it) } }) + } + val percent by + produceState(1f) { + while (true) { + delay(300) + value = Random.nextDouble(from = 0.3, until = 1.0).toFloat() + delay(300) + list = list.shuffled() + } + } + LookaheadScope { + Column { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + Modifier.animateBounds(this@LookaheadScope, Modifier.fillMaxHeight(percent)) + .border(BorderStroke(2.dp, Color.Blue)) + ) { + items(list, key = { it }) { + Text( + "Item $it", + Modifier.animateItem() + .padding(5.dp) + .height(80.dp) + .background(pastelColors[it % pastelColors.size]) + ) + } + } + } + } +} +// +// @SuppressLint("PrimitiveInCollection") +// @Preview +// @Composable +// fun ListAnimateItemSample() { +// var list by remember { +// mutableStateOf>(mutableListOf().apply { repeat(20) { add(it) } }) +// } +// LookaheadScope { +// Column { +// Button(onClick = { list = list + list.size }) { Text("Add new item") } +// Button(onClick = { list = list.shuffled() }) { Text("Shuffle") } +// LazyColumn( +// Modifier +// .background(Color.LightGray) +// .layout { m, _ -> +// m.measure( +// constraints = Constraints.fixed(600, if (isLookingAhead) 600 else 800) +// ) +// .run { layout(width, height) { place(0, 0) } } +// } +// ) { +// items(list, key = { it }) { Text("Item $it", Modifier.animateItem()) } +// } +// } +// } +// } diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/LazyGridSharedElementDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/LazyGridSharedElementDemo.kt new file mode 100644 index 0000000000000..dd4024d959946 --- /dev/null +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/LazyGridSharedElementDemo.kt @@ -0,0 +1,157 @@ +/* + * 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. + */ + +package androidx.compose.animation.demos.sharedelement + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.demos.R +import androidx.compose.animation.demos.layoutanimation.summerColors +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +sealed class State + +object List : State() + +class Details(val index: Int) : State() + +@OptIn(ExperimentalSharedTransitionApi::class) +@Preview +@Composable +fun LazyGridSharedElementDemo() { + var target: State by remember { mutableStateOf(List) } + SharedTransitionLayout { + AnimatedContent( + targetState = target, + transitionSpec = { + (fadeIn() togetherWith fadeOut()).apply { + targetContentZIndex = if (targetState == List) 1f else 0f + } + } + ) { + if (it == List) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(5.dp) + ) { + items(20) { + if (it % 5 == 0) { + // cat + Image( + painterResource(catResIds[(it / 5) % catResIds.size]), + null, + Modifier.clickable { target = Details(it) } + .aspectRatio(1f) + .padding(5.dp) + .sharedElement( + rememberSharedContentState("cat $it"), + // Using the AnimatedVisibilityScope from the + // AnimatedContent + // defined above. + this@AnimatedContent, + ), + contentScale = ContentScale.Crop + ) + } else { + Box( + Modifier.aspectRatio(1f) + .padding(5.dp) + .background(summerColors[it % 5 % 4], RoundedCornerShape(10.dp)) + .fillMaxWidth() + ) + } + } + } + } else if (it is Details) { + Column(Modifier.fillMaxSize().clickable { target = List }) { + Image( + painterResource(catResIds[(it.index / 5) % catResIds.size]), + contentDescription = null, + Modifier.fillMaxWidth() + .aspectRatio(1f) + // Creating a shared element. Note that this modifier is *after* + // the size modifier and aspectRatio modifier, because those size specs + // are not shared between the two shared elements. + .sharedElement( + rememberSharedContentState("cat ${it.index}"), + // Using the AnimatedVisibilityScope from the AnimatedContent + // defined above. + this@AnimatedContent, + ), + contentScale = ContentScale.Crop + ) + Text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent fringilla" + + " mollis efficitur. Maecenas sit amet urna eu urna blandit suscipit efficitur" + + " eget mauris. Nullam eget aliquet ligula. Nunc id euismod elit. Morbi aliquam" + + " enim eros, eget consequat dolor consequat id. Quisque elementum faucibus" + + " congue. Curabitur mollis aliquet turpis, ut pellentesque justo eleifend nec.\n" + + "\n" + + "Suspendisse ac consequat turpis, euismod lacinia quam. Nulla lacinia tellus" + + " eu felis tristique ultricies. Vivamus et ultricies dolor. Orci varius" + + " natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus." + + " Ut gravida porttitor arcu elementum elementum. Phasellus ultrices vel turpis" + + " volutpat mollis. Vivamus leo diam, placerat quis leo efficitur, ultrices" + + " placerat ex. Nullam mollis et metus ac ultricies. Ut ligula metus, congue" + + " gravida metus in, vestibulum posuere velit. Sed et ex nisl. Fusce tempor" + + " odio eget sapien pellentesque, sed cursus velit fringilla. Nullam odio" + + " ipsum, eleifend non consectetur vitae, congue id libero. Etiam tincidunt" + + " mauris at urna dictum ornare.\n" + + "\n", + // Prefer Modifier.sharedBounds for text, unless the texts in both initial + // content and target content are exactly the same (i.e. same + // size/font/color) + modifier = + Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally) + ) + } + } + } + } +} + +@SuppressLint("PrimitiveInCollection") +private val catResIds = listOf(R.drawable.yt_profile, R.drawable.pepper, R.drawable.yt_profile2) diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/SharedElementInLazyStaggeredGridDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/SharedElementInLazyStaggeredGridDemo.kt new file mode 100644 index 0000000000000..5df11d7b2f50b --- /dev/null +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/sharedelement/SharedElementInLazyStaggeredGridDemo.kt @@ -0,0 +1,219 @@ +/* + * 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. + */ + +package androidx.compose.animation.demos.sharedelement + +import android.annotation.SuppressLint +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.demos.R +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +private val listCats = + listOf( + Cat("YT", "", R.drawable.yt_profile), + Cat("Waffle", "", R.drawable.waffle), + Cat("YT Also", "", R.drawable.yt_profile2), + Cat("Pepper", "", R.drawable.pepper), + Cat("YT Yet Again", "", R.drawable.yt_profile), + Cat("Still Waffle", "", R.drawable.waffle), + Cat("Pepper Take 2", "", R.drawable.pepper), + ) + +@OptIn(ExperimentalSharedTransitionApi::class) +private val boundsTransition = BoundsTransform { _, _ -> tween(500) } +private val shapeForSharedElement = RoundedCornerShape(16.dp) + +@OptIn(ExperimentalSharedTransitionApi::class) +@Preview +@Composable +private fun AnimatedVisibilitySharedElementExample() { + var selectedCat by remember { mutableStateOf(null) } + + SharedTransitionLayout(modifier = Modifier.fillMaxSize()) { + AnimatedContent(selectedCat) { + if (it == null) { + LazyVerticalStaggeredGrid( + modifier = + Modifier.fillMaxSize() + .background(Color.LightGray.copy(alpha = 0.5f)) + .drawWithContent { drawContent() }, + columns = StaggeredGridCells.Adaptive(150.dp), + verticalItemSpacing = 8.dp, + horizontalArrangement = Arrangement.run { spacedBy(8.dp) } + ) { + itemsIndexed(listCats, key = { index, _ -> index }) { _, cat -> + CatItem( + cat = cat, + onClick = { selectedCat = cat }, + scope = this@AnimatedContent, + modifier = Modifier.animateItem(placementSpec = tween(500)) + ) + } + } + } else { + CatDetails(cat = it, this@AnimatedContent, onConfirmClick = { selectedCat = null }) + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun SharedTransitionScope.CatItem( + cat: Cat, + onClick: () -> Unit, + scope: AnimatedVisibilityScope, + modifier: Modifier = Modifier +) { + Box( + modifier = + modifier + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "${cat.name}-bounds"), + boundsTransform = boundsTransition, + animatedVisibilityScope = scope, + clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement) + ) + .background(Color.White, shapeForSharedElement) + .clip(shapeForSharedElement) + ) { + CatContent( + cat = cat, + modifier = + Modifier.sharedElement( + rememberSharedContentState(key = cat.name), + animatedVisibilityScope = scope, + boundsTransform = boundsTransition, + ), + onClick = onClick + ) + } +} + +@SuppressLint("UnnecessaryLambdaCreation") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun SharedTransitionScope.CatDetails( + cat: Cat, + scope: AnimatedVisibilityScope, + onConfirmClick: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center + ) { + Column( + modifier = + Modifier.padding(horizontal = 16.dp) + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "${cat.name}-bounds"), + animatedVisibilityScope = scope, + boundsTransform = boundsTransition, + clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement) + ) + .background(Color.White, shapeForSharedElement) + .clip(shapeForSharedElement) + ) { + CatContent( + cat = cat, + modifier = + Modifier.sharedElement( + rememberSharedContentState(key = cat.name), + animatedVisibilityScope = scope, + boundsTransform = boundsTransition, + ), + onClick = { onConfirmClick() } + ) + Text( + text = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent fringilla" + + " mollis efficitur. Maecenas sit amet urna eu urna blandit suscipit efficitur" + + " eget mauris. Nullam eget aliquet ligula. Nunc id euismod elit. Morbi aliquam" + + " enim eros, eget consequat dolor consequat id. Quisque elementum faucibus" + + " congue. Curabitur mollis aliquet turpis, ut pellentesque justo eleifend nec.\n" + + "\n" + + "Suspendisse ac consequat turpis, euismod lacinia quam. Nulla lacinia tellus" + + " eu felis tristique ultricies. Vivamus et ultricies dolor. Orci varius" + + " natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus." + + " Ut gravida porttitor arcu elementum elementum. Phasellus ultrices vel turpis" + + " volutpat mollis. Vivamus leo diam, placerat quis leo efficitur, ultrices" + + " placerat ex. Nullam mollis et metus ac ultricies. Ut ligula metus, congue" + + " gravida metus in, vestibulum posuere velit. Sed et ex nisl. Fusce tempor" + + " odio eget sapien pellentesque, sed cursus velit fringilla. Nullam odio" + + " ipsum, eleifend non consectetur vitae, congue id libero. Etiam tincidunt" + + " mauris at urna dictum ornare.\n" + ) + } + } +} + +@SuppressLint("UnnecessaryLambdaCreation") +@Composable +fun CatContent(cat: Cat, modifier: Modifier = Modifier, onClick: () -> Unit) { + Column(modifier = modifier.clickable { onClick() }) { + Image( + painter = painterResource(id = cat.image), + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + contentDescription = null + ) + Text( + text = cat.name, + modifier = Modifier.wrapContentWidth().padding(8.dp), + ) + } +} + +data class Cat(val name: String, val description: String, @DrawableRes val image: Int) { + override fun toString(): String { + return name + } +} diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimatedContentTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimatedContentTest.kt index 5611700662f93..b606700ec7c22 100644 --- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimatedContentTest.kt +++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimatedContentTest.kt @@ -31,6 +31,7 @@ import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -46,6 +47,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder @@ -60,6 +62,7 @@ import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -92,6 +95,7 @@ import org.junit.runner.RunWith @LargeTest class AnimatedContentTest { val rule = createComposeRule() + // Detect leaks BEFORE and AFTER compose rule work @get:Rule val ruleChain: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()).around(rule) @@ -1154,6 +1158,76 @@ class AnimatedContentTest { } } + @Test + fun testRecreatingTransitionInAnimatedContent() { + var toggle by mutableStateOf(true) + var targetState by mutableStateOf(true) + var currentSize = IntSize(200, 200) + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + val transition = key(toggle) { updateTransition(targetState) } + Column { + transition.AnimatedContent( + modifier = + Modifier.onSizeChanged { + currentSize = it + assertNotEquals(IntSize.Zero, it) + }, + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { + if (it) { + Box(Modifier.background(Color.Red).size(200.dp)) + } else { + Box(Modifier.background(Color.Green).size(300.dp)) + } + } + } + } + } + rule.runOnIdle { toggle = !toggle } + rule.waitForIdle() + rule.mainClock.autoAdvance = false + targetState = !targetState + while (currentSize == IntSize(200, 200)) { + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + } + var lastSize = IntSize(200, 200) + var frameCount = 0 + while (currentSize.width < 290f && currentSize.height < 290f || frameCount < 10) { + // Assert that the size is monotonically increasing, never jumps to 0 + assert(lastSize.width < currentSize.width) + assert(lastSize.height < currentSize.height) + lastSize = currentSize + rule.mainClock.advanceTimeByFrame() + frameCount++ + } + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + // Now recreate the transition again + rule.runOnIdle { toggle = !toggle } + rule.waitForIdle() + rule.mainClock.autoAdvance = false + targetState = !targetState + while (currentSize == IntSize(300, 300)) { + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + } + lastSize = IntSize(300, 300) + frameCount = 0 + while (currentSize.width > 210f && currentSize.height > 210f || frameCount < 10) { + // Assert that the size is monotonically increasing, never jumps to 0 + assert(lastSize.width > currentSize.width) + assert(lastSize.height > currentSize.height) + lastSize = currentSize + rule.mainClock.advanceTimeByFrame() + frameCount++ + } + rule.mainClock.autoAdvance = true + rule.waitForIdle() + } + private fun assertOffsetEquals(expected: Offset, actual: Offset) { assertEquals(expected.x, actual.x, 0.00001f) assertEquals(expected.y, actual.y, 0.00001f) diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimationModifierTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimationModifierTest.kt index 078ecf5022a21..dd33522fdd205 100644 --- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimationModifierTest.kt +++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimationModifierTest.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.InspectableValue import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.isDebugInspectorInfoEnabled import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Constraints @@ -158,70 +159,13 @@ class AnimationModifierTest { } @Test - fun testAlignmentInAnimateContentSize() { - // Tests that Alignment is consistent when used in Modifier.animateContentSize() - val alignmentList = listOf(Alignment.TopStart, Alignment.Center, Alignment.BottomEnd) - val startWidth = 100 - val endWidth = 150 - val startHeight = 400 - val endHeight = 200 - var width by mutableStateOf(startWidth) - var height by mutableStateOf(startHeight) - - val density = rule.density.density - - val frameDuration = 16 - val animDuration = 10 * frameDuration - - val positionInRootByBoxIndex = mutableMapOf() - - @Composable - fun AnimateBoxSizeWithAlignment(alignment: Alignment, index: Int) { - Box( - Modifier.animateContentSize( - animationSpec = tween(animDuration, easing = LinearOutSlowInEasing), - alignment = alignment - ) - .onPlaced { positionInRootByBoxIndex[index] = it.positionInRoot() } - .requiredSize(width.dp, height.dp) - ) - } - - rule.mainClock.autoAdvance = false - rule.setContent { - alignmentList.forEachIndexed { index, alignment -> - AnimateBoxSizeWithAlignment(index = index, alignment = alignment) - } - } - - rule.runOnUiThread { - width = endWidth - height = endHeight - } - rule.mainClock.advanceTimeByFrame() - rule.mainClock.advanceTimeByFrame() - rule.waitForIdle() - - val size = with(rule.density) { IntSize(endWidth.dp.roundToPx(), endHeight.dp.roundToPx()) } - - for (i in 0..animDuration step frameDuration) { - val fraction = LinearOutSlowInEasing.transform(i / animDuration.toFloat()) - val expectedWidth = density * (startWidth * (1 - fraction) + endWidth * fraction) - val expectedHeight = density * (startHeight * (1 - fraction) + endHeight * fraction) - val space = IntSize(expectedWidth.roundToInt(), expectedHeight.roundToInt()) - - // Test all boxes at the current frame - for (alignIndex in alignmentList.indices) { - val expectedPosition = - alignmentList[alignIndex].align(size, space, LayoutDirection.Ltr).toOffset() - val positionInRoot = positionInRootByBoxIndex[alignIndex]!! - assertEquals(expectedPosition.x, positionInRoot.x, 1f) - assertEquals(expectedPosition.y, positionInRoot.y, 1f) - } + fun testAlignmentInAnimateContentSize_underLtr() { + assertAlignmentInAnimateContentSize(LayoutDirection.Ltr) + } - rule.mainClock.advanceTimeBy(frameDuration.toLong()) - rule.waitForIdle() - } + @Test + fun testAlignmentInAnimateContentSize_underRtl() { + assertAlignmentInAnimateContentSize(LayoutDirection.Rtl) } @Test @@ -365,6 +309,80 @@ class AnimationModifierTest { assertEquals(largeSizePx, testModifier.width) assertEquals(largeSizePx, testModifier.height) } + + /** + * Verifies Alignment behavior when used with animateContentSize. + * + * @param layoutDirection LayoutDirection applied to the Compose UI, this is also used to + * manually verify alignment values with [Alignment.align]. + */ + private fun assertAlignmentInAnimateContentSize(layoutDirection: LayoutDirection) { + val alignmentList = listOf(Alignment.TopStart, Alignment.Center, Alignment.BottomEnd) + + val startWidth = 100 + val endWidth = 150 + val startHeight = 400 + val endHeight = 200 + var width by mutableStateOf(startWidth) + var height by mutableStateOf(startHeight) + + val density = rule.density.density + + val frameDuration = 16 + val animDuration = 10 * frameDuration + + val positionInRootByBoxIndex = mutableMapOf() + + @Composable + fun AnimateBoxSizeWithAlignment(alignment: Alignment, index: Int) { + Box( + Modifier.animateContentSize( + animationSpec = tween(animDuration, easing = LinearOutSlowInEasing), + alignment = alignment + ) + .onPlaced { positionInRootByBoxIndex[index] = it.positionInRoot() } + .requiredSize(width.dp, height.dp) + ) + } + + rule.mainClock.autoAdvance = false + rule.setContent { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + alignmentList.forEachIndexed { index, alignment -> + AnimateBoxSizeWithAlignment(index = index, alignment = alignment) + } + } + } + + rule.runOnUiThread { + width = endWidth + height = endHeight + } + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + + val size = with(rule.density) { IntSize(endWidth.dp.roundToPx(), endHeight.dp.roundToPx()) } + + for (i in 0..animDuration step frameDuration) { + val fraction = LinearOutSlowInEasing.transform(i / animDuration.toFloat()) + val expectedWidth = density * (startWidth * (1 - fraction) + endWidth * fraction) + val expectedHeight = density * (startHeight * (1 - fraction) + endHeight * fraction) + val space = IntSize(expectedWidth.roundToInt(), expectedHeight.roundToInt()) + + // Test all boxes at the current frame + for (alignIndex in alignmentList.indices) { + val expectedPosition = + alignmentList[alignIndex].align(size, space, layoutDirection).toOffset() + val positionInRoot = positionInRootByBoxIndex[alignIndex]!! + assertEquals(expectedPosition.x, positionInRoot.x, 1f) + assertEquals(expectedPosition.y, positionInRoot.y, 1f) + } + + rule.mainClock.advanceTimeBy(frameDuration.toLong()) + rule.waitForIdle() + } + } } internal class TestModifier : LayoutModifier { diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt index b80aaf7943414..f4967da95ae59 100644 --- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt +++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt @@ -1652,7 +1652,8 @@ class SharedTransitionTest { clipInOverlayDuringTransition = object : SharedTransitionScope.OverlayClip { override fun getClipPath( - state: SharedTransitionScope.SharedContentState, + sharedContentState: + SharedTransitionScope.SharedContentState, bounds: Rect, layoutDirection: LayoutDirection, density: Density diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimatedContent.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimatedContent.kt index a8d32eafa840d..6a90501a63fe0 100644 --- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimatedContent.kt +++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimatedContent.kt @@ -580,7 +580,7 @@ internal constructor( // Keep the SizeModifier in the chain and switch between active animating and // passive // observing based on sizeAnimation's value - SizeModifierElement(sizeAnimation, sizeTransform) + SizeModifierElement(sizeAnimation, sizeTransform, this) ) } @@ -596,39 +596,44 @@ internal constructor( } } - private inner class SizeModifierElement( + private class SizeModifierElement( val sizeAnimation: Transition.DeferredAnimation?, - val sizeTransform: State - ) : ModifierNodeElement() { - override fun create(): SizeModifierNode { - return SizeModifierNode(sizeAnimation, sizeTransform) + val sizeTransform: State, + val scope: AnimatedContentTransitionScopeImpl + ) : ModifierNodeElement>() { + override fun create(): SizeModifierNode { + return SizeModifierNode(sizeAnimation, sizeTransform, scope) } override fun hashCode(): Int { - return sizeAnimation.hashCode() * 31 + sizeTransform.hashCode() + return (31 * scope.hashCode() + sizeAnimation.hashCode()) * 31 + + sizeTransform.hashCode() } override fun equals(other: Any?): Boolean { - return other is AnimatedContentTransitionScopeImpl<*>.SizeModifierElement && + return other is SizeModifierElement<*> && other.sizeAnimation == sizeAnimation && other.sizeTransform == sizeTransform } - override fun update(node: SizeModifierNode) { + override fun update(node: SizeModifierNode) { node.sizeAnimation = sizeAnimation node.sizeTransform = sizeTransform + node.scope = scope } override fun InspectorInfo.inspectableProperties() { name = "sizeTransform" properties["sizeAnimation"] = sizeAnimation properties["sizeTransform"] = sizeTransform + properties["scope"] = scope } } - private inner class SizeModifierNode( + private class SizeModifierNode( var sizeAnimation: Transition.DeferredAnimation?, var sizeTransform: State, + var scope: AnimatedContentTransitionScopeImpl, ) : LayoutModifierNodeWithPassThroughIntrinsics() { // This is used to track the on-going size change so that when the target state changes, // we always start from the last seen size to the new target size to ensure continuity. @@ -637,6 +642,11 @@ internal constructor( private fun lastContinuousSizeOrDefault(default: IntSize) = if (lastSize == UnspecifiedSize) default else lastSize + override fun onReset() { + super.onReset() + lastSize = UnspecifiedSize + } + override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints @@ -655,33 +665,30 @@ internal constructor( sizeAnimation!!.animate( transitionSpec = { val initial = - if ( - initialState == - this@AnimatedContentTransitionScopeImpl.initialState - ) { + if (initialState == scope.initialState) { lastContinuousSizeOrDefault(currentSize) } else { - targetSizeMap[initialState]?.value ?: IntSize.Zero + scope.targetSizeMap[initialState]?.value ?: IntSize.Zero } - val target = targetSizeMap[targetState]?.value ?: IntSize.Zero + val target = scope.targetSizeMap[targetState]?.value ?: IntSize.Zero sizeTransform.value?.createAnimationSpec(initial, target) ?: spring(stiffness = Spring.StiffnessMediumLow) } ) { // Animate from the approach size to the lookahead size. - if (it == initialState) { + if (it == scope.initialState) { lastContinuousSizeOrDefault(currentSize) } else { - targetSizeMap[it]?.value ?: IntSize.Zero + scope.targetSizeMap[it]?.value ?: IntSize.Zero } } - animatedSize = size + scope.animatedSize = size measuredSize = size.value lastSize = size.value } return layout(measuredSize.width, measuredSize.height) { val offset = - contentAlignment.align( + scope.contentAlignment.align( IntSize(placeable.width, placeable.height), measuredSize, LayoutDirection.Ltr diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimationModifier.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimationModifier.kt index 8102e2cb4ff91..d2642030e63dc 100644 --- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimationModifier.kt +++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimationModifier.kt @@ -212,7 +212,7 @@ private class SizeAnimationModifierNode( space = IntSize(width, height), layoutDirection = this@measure.layoutDirection ) - placeable.placeRelative(offset) + placeable.place(offset) } } diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt index f2b32d9e5d5ae..d3d82483d9e10 100644 --- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt +++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt @@ -380,9 +380,9 @@ public interface SharedTransitionScope : LookaheadScope { public interface OverlayClip { /** * Creates a clip path based using current animated [bounds] of the [sharedBounds] or - * [sharedElement], their [state] (to query parent state's bounds if needed), and - * [layoutDirection] and [density]. The topLeft of the [bounds] is the local position of the - * sharedElement/sharedBounds in the [SharedTransitionScope]. + * [sharedElement], their [sharedContentState] (to query parent state's bounds if needed), + * and [layoutDirection] and [density]. The topLeft of the [bounds] is the local position of + * the sharedElement/sharedBounds in the [SharedTransitionScope]. * * **Important**: The returned [Path] needs to be offset-ed as needed such that it is in * [SharedTransitionScope.lookaheadScopeCoordinates]'s coordinate space. For example, if the @@ -392,7 +392,7 @@ public interface SharedTransitionScope : LookaheadScope { * creating new [Path]s. */ public fun getClipPath( - state: SharedContentState, + sharedContentState: SharedContentState, bounds: Rect, layoutDirection: LayoutDirection, density: Density @@ -456,9 +456,8 @@ public interface SharedTransitionScope : LookaheadScope { * @sample androidx.compose.animation.samples.SharedElementInAnimatedContentSample * @see [sharedBounds] */ - @OptIn(ExperimentalAnimationApi::class) public fun Modifier.sharedElement( - state: SharedContentState, + sharedContentState: SharedContentState, animatedVisibilityScope: AnimatedVisibilityScope, boundsTransform: BoundsTransform = DefaultBoundsTransform, placeHolderSize: PlaceHolderSize = contentSize, @@ -536,7 +535,6 @@ public interface SharedTransitionScope : LookaheadScope { * @sample androidx.compose.animation.samples.NestedSharedBoundsSample * @see [sharedBounds] */ - @OptIn(ExperimentalAnimationApi::class) public fun Modifier.sharedBounds( sharedContentState: SharedContentState, animatedVisibilityScope: AnimatedVisibilityScope, @@ -705,7 +703,7 @@ internal constructor(lookaheadScope: LookaheadScope, val coroutineScope: Corouti ) override fun Modifier.sharedElement( - state: SharedContentState, + sharedContentState: SharedContentState, animatedVisibilityScope: AnimatedVisibilityScope, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, @@ -714,7 +712,7 @@ internal constructor(lookaheadScope: LookaheadScope, val coroutineScope: Corouti clipInOverlayDuringTransition: OverlayClip ) = this.sharedBoundsImpl( - state, + sharedContentState, parentTransition = animatedVisibilityScope.transition, visible = { it == EnterExitState.Visible }, boundsTransform = boundsTransform, @@ -725,7 +723,6 @@ internal constructor(lookaheadScope: LookaheadScope, val coroutineScope: Corouti clipInOverlayDuringTransition = clipInOverlayDuringTransition ) - @OptIn(ExperimentalAnimationApi::class) override fun Modifier.sharedBounds( sharedContentState: SharedContentState, animatedVisibilityScope: AnimatedVisibilityScope, @@ -1140,7 +1137,7 @@ internal constructor(lookaheadScope: LookaheadScope, val coroutineScope: Corouti private val path = Path() override fun getClipPath( - state: SharedContentState, + sharedContentState: SharedContentState, bounds: Rect, layoutDirection: LayoutDirection, density: Density @@ -1253,12 +1250,12 @@ private val DefaultSpring = private val ParentClip: OverlayClip = object : OverlayClip { override fun getClipPath( - state: SharedContentState, + sharedContentState: SharedContentState, bounds: Rect, layoutDirection: LayoutDirection, density: Density ): Path? { - return state.parentSharedContentState?.clipPathInOverlay + return sharedContentState.parentSharedContentState?.clipPathInOverlay } } diff --git a/compose/foundation/foundation-layout/api/current.txt b/compose/foundation/foundation-layout/api/current.txt index 45e5e44e1c5ba..0576ad7ad8a10 100644 --- a/compose/foundation/foundation-layout/api/current.txt +++ b/compose/foundation/foundation-layout/api/current.txt @@ -22,14 +22,14 @@ package androidx.compose.foundation.layout { method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical spacedBy(float space); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Horizontal spacedBy(float space, androidx.compose.ui.Alignment.Horizontal alignment); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Vertical spacedBy(float space, androidx.compose.ui.Alignment.Vertical alignment); - property public final androidx.compose.foundation.layout.Arrangement.Vertical Bottom; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical Center; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal End; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceAround; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceBetween; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceEvenly; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Start; - property public final androidx.compose.foundation.layout.Arrangement.Vertical Top; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Vertical Bottom; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical Center; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal End; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceAround; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceBetween; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceEvenly; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Start; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Vertical Top; field public static final androidx.compose.foundation.layout.Arrangement INSTANCE; } @@ -44,12 +44,12 @@ package androidx.compose.foundation.layout { method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical spacedBy(float space); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Horizontal spacedBy(float space, androidx.compose.ui.Alignment.Horizontal alignment); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Vertical spacedBy(float space, androidx.compose.ui.Alignment.Vertical alignment); - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Center; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Left; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Right; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceAround; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceBetween; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceEvenly; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Center; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Left; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Right; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceAround; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceBetween; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceEvenly; field public static final androidx.compose.foundation.layout.Arrangement.Absolute INSTANCE; } @@ -191,8 +191,8 @@ package androidx.compose.foundation.layout { @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowColumnOverflowScope extends androidx.compose.foundation.layout.FlowColumnScope { method public int getShownItemCount(); method public int getTotalItemCount(); - property public abstract int shownItemCount; - property public abstract int totalItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int shownItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int totalItemCount; } @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowColumnScope extends androidx.compose.foundation.layout.ColumnScope { @@ -223,20 +223,14 @@ package androidx.compose.foundation.layout { @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowRowOverflowScope extends androidx.compose.foundation.layout.FlowRowScope { method public int getShownItemCount(); method public int getTotalItemCount(); - property public abstract int shownItemCount; - property public abstract int totalItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int shownItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int totalItemCount; } @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowRowScope extends androidx.compose.foundation.layout.RowScope { method @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public androidx.compose.ui.Modifier fillMaxRowHeight(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction); } - public final class InsetsHelper_androidKt { - method @androidx.compose.runtime.Composable public static androidx.core.graphics.Insets roundToAndroidXInsets(androidx.compose.ui.unit.DpRect); - method public static androidx.core.graphics.Insets toAndroidXInsets(androidx.compose.ui.unit.IntRect); - method public static androidx.compose.ui.unit.IntRect toComposeIntRect(androidx.core.graphics.Insets); - } - public final class IntrinsicKt { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier height(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize); method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier requiredHeight(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize); @@ -453,7 +447,7 @@ package androidx.compose.foundation.layout { method @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static boolean isTappableElementVisible(androidx.compose.foundation.layout.WindowInsets.Companion); method public static void setConsumeWindowInsets(androidx.compose.ui.platform.AbstractComposeView, boolean); method public void setConsumeWindowInsets(boolean); - property public boolean consumeWindowInsets; + property @Deprecated public boolean consumeWindowInsets; } } diff --git a/compose/foundation/foundation-layout/api/restricted_current.txt b/compose/foundation/foundation-layout/api/restricted_current.txt index 0d70ee8229b11..1a68ea902778b 100644 --- a/compose/foundation/foundation-layout/api/restricted_current.txt +++ b/compose/foundation/foundation-layout/api/restricted_current.txt @@ -22,14 +22,14 @@ package androidx.compose.foundation.layout { method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical spacedBy(float space); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Horizontal spacedBy(float space, androidx.compose.ui.Alignment.Horizontal alignment); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Vertical spacedBy(float space, androidx.compose.ui.Alignment.Vertical alignment); - property public final androidx.compose.foundation.layout.Arrangement.Vertical Bottom; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical Center; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal End; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceAround; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceBetween; - property public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceEvenly; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Start; - property public final androidx.compose.foundation.layout.Arrangement.Vertical Top; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Vertical Bottom; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical Center; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal End; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceAround; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceBetween; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical SpaceEvenly; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Start; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Vertical Top; field public static final androidx.compose.foundation.layout.Arrangement INSTANCE; } @@ -44,12 +44,12 @@ package androidx.compose.foundation.layout { method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical spacedBy(float space); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Horizontal spacedBy(float space, androidx.compose.ui.Alignment.Horizontal alignment); method @androidx.compose.runtime.Stable public androidx.compose.foundation.layout.Arrangement.Vertical spacedBy(float space, androidx.compose.ui.Alignment.Vertical alignment); - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Center; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Left; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal Right; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceAround; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceBetween; - property public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceEvenly; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Center; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Left; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal Right; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceAround; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceBetween; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.layout.Arrangement.Horizontal SpaceEvenly; field public static final androidx.compose.foundation.layout.Arrangement.Absolute INSTANCE; } @@ -105,6 +105,7 @@ package androidx.compose.foundation.layout { public final class ColumnKt { method @androidx.compose.runtime.Composable public static inline void Column(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, kotlin.jvm.functions.Function1 content); method @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.compose.ui.layout.MeasurePolicy columnMeasurePolicy(androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, androidx.compose.ui.Alignment.Horizontal horizontalAlignment); + property @kotlin.PublishedApi internal static final androidx.compose.ui.layout.MeasurePolicy DefaultColumnMeasurePolicy; field @kotlin.PublishedApi internal static final androidx.compose.ui.layout.MeasurePolicy DefaultColumnMeasurePolicy; } @@ -195,8 +196,8 @@ package androidx.compose.foundation.layout { @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowColumnOverflowScope extends androidx.compose.foundation.layout.FlowColumnScope { method public int getShownItemCount(); method public int getTotalItemCount(); - property public abstract int shownItemCount; - property public abstract int totalItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int shownItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int totalItemCount; } @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowColumnScope extends androidx.compose.foundation.layout.ColumnScope { @@ -229,20 +230,14 @@ package androidx.compose.foundation.layout { @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowRowOverflowScope extends androidx.compose.foundation.layout.FlowRowScope { method public int getShownItemCount(); method public int getTotalItemCount(); - property public abstract int shownItemCount; - property public abstract int totalItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int shownItemCount; + property @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public abstract int totalItemCount; } @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Stable public interface FlowRowScope extends androidx.compose.foundation.layout.RowScope { method @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi public androidx.compose.ui.Modifier fillMaxRowHeight(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction); } - public final class InsetsHelper_androidKt { - method @androidx.compose.runtime.Composable public static androidx.core.graphics.Insets roundToAndroidXInsets(androidx.compose.ui.unit.DpRect); - method public static androidx.core.graphics.Insets toAndroidXInsets(androidx.compose.ui.unit.IntRect); - method public static androidx.compose.ui.unit.IntRect toComposeIntRect(androidx.core.graphics.Insets); - } - public final class IntrinsicKt { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier height(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize); method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier requiredHeight(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize); @@ -309,6 +304,7 @@ package androidx.compose.foundation.layout { public final class RowKt { method @androidx.compose.runtime.Composable public static inline void Row(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, kotlin.jvm.functions.Function1 content); method @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.compose.ui.layout.MeasurePolicy rowMeasurePolicy(androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, androidx.compose.ui.Alignment.Vertical verticalAlignment); + property @kotlin.PublishedApi internal static final androidx.compose.ui.layout.MeasurePolicy DefaultRowMeasurePolicy; field @kotlin.PublishedApi internal static final androidx.compose.ui.layout.MeasurePolicy DefaultRowMeasurePolicy; } @@ -461,7 +457,7 @@ package androidx.compose.foundation.layout { method @SuppressCompatibility @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable public static boolean isTappableElementVisible(androidx.compose.foundation.layout.WindowInsets.Companion); method public static void setConsumeWindowInsets(androidx.compose.ui.platform.AbstractComposeView, boolean); method public void setConsumeWindowInsets(boolean); - property public boolean consumeWindowInsets; + property @Deprecated public boolean consumeWindowInsets; } } diff --git a/compose/foundation/foundation-layout/proguard-rules.pro b/compose/foundation/foundation-layout/proguard-rules.pro index d07663a71a2cc..67d118b98fa5e 100644 --- a/compose/foundation/foundation-layout/proguard-rules.pro +++ b/compose/foundation/foundation-layout/proguard-rules.pro @@ -17,6 +17,8 @@ # reduce the amount of code generated at the call site. -keep,allowshrinking,allowobfuscation class androidx.compose.**.* { static void throw*Exception(...); + static void throw*ExceptionForNullCheck(...); # For methods returning Nothing static java.lang.Void throw*Exception(...); + static java.lang.Void throw*ExceptionForNullCheck(...); } diff --git a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/ContextualFlowRowColumnTest.kt b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/ContextualFlowRowColumnTest.kt index 3aa9880441257..086f752c7349f 100644 --- a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/ContextualFlowRowColumnTest.kt +++ b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/ContextualFlowRowColumnTest.kt @@ -545,7 +545,7 @@ class ContextualFlowRowColumnTest { horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(20.dp), maxItemsInEachRow = 3, - ) { + ) { index -> Box( Modifier.onSizeChanged { listOfHeights.add(it.height) } .width(100.dp) @@ -553,7 +553,7 @@ class ContextualFlowRowColumnTest { .background(Color.Green) .fillMaxRowHeight() ) { - val height = Random.Default.nextInt(1, 200) - it + val height = 200 - index Box(modifier = Modifier.height(height.dp)) } } diff --git a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/InsetsHelper.kt b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/InsetsHelper.kt deleted file mode 100644 index a5e3a114705b4..0000000000000 --- a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/InsetsHelper.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.foundation.layout - -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.DpRect -import androidx.compose.ui.unit.dp -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@SmallTest -@RunWith(AndroidJUnit4::class) -class InsetsHelperTest { - @get:Rule val rule = createComposeRule() - - @Test - fun dpRectToAndroidXInsetsConverts() { - lateinit var androidXInsets: androidx.core.graphics.Insets - lateinit var density: Density - - rule.setContent { - androidXInsets = DpRect(5.dp, 6.dp, 7.dp, 8.dp).roundToAndroidXInsets() - density = LocalDensity.current - } - - assertEquals(with(density) { 5.dp.roundToPx() }, androidXInsets.left) - assertEquals(with(density) { 6.dp.roundToPx() }, androidXInsets.top) - assertEquals(with(density) { 7.dp.roundToPx() }, androidXInsets.right) - assertEquals(with(density) { 8.dp.roundToPx() }, androidXInsets.bottom) - } -} diff --git a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsDeviceTest.kt b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsDeviceTest.kt index cd3b2549b0ba1..6614d3533eb76 100644 --- a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsDeviceTest.kt +++ b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsDeviceTest.kt @@ -46,11 +46,10 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.children -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -65,28 +64,23 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class WindowInsetsDeviceTest { @get:Rule val rule = createAndroidComposeRule() - private lateinit var finishLatch: CountDownLatch - private val finishLatchGetter - get() = finishLatch - - private val observer = - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - finishLatchGetter.countDown() - } - } @Before fun setup() { rule.activity.createdLatch.await(1, TimeUnit.SECONDS) - finishLatch = CountDownLatch(1) - rule.runOnUiThread { rule.activity.lifecycle.addObserver(observer) } } @After - fun tearDown() { - rule.runOnUiThread { rule.activity.finish() } - assertThat(finishLatch.await(1, TimeUnit.SECONDS)).isTrue() + fun teardown() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = rule.activity + while (!activity.isDestroyed) { + instrumentation.runOnMainSync { + if (!activity.isDestroyed) { + activity.finish() + } + } + } } @OptIn(ExperimentalLayoutApi::class) diff --git a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt index a3a1bc5d4c48b..2748f65d6ea4d 100644 --- a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt +++ b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt @@ -58,15 +58,12 @@ import androidx.core.graphics.Insets as AndroidXInsets import androidx.core.view.DisplayCutoutCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.forEach -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import kotlin.math.roundToInt import org.junit.After import org.junit.Before @@ -81,29 +78,23 @@ class WindowInsetsPaddingTest { private lateinit var insetsView: InsetsView - private lateinit var finishLatch: CountDownLatch - private val finishLatchGetter - get() = finishLatch - - private val observer = - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - finishLatchGetter.countDown() - } - } - @Before fun setup() { WindowInsetsHolder.setUseTestInsets(true) - finishLatch = CountDownLatch(1) - rule.runOnUiThread { rule.activity.lifecycle.addObserver(observer) } } @After fun teardown() { WindowInsetsHolder.setUseTestInsets(false) - rule.runOnUiThread { rule.activity.finish() } - assertThat(finishLatch.await(1, TimeUnit.SECONDS)).isTrue() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = rule.activity + while (!activity.isDestroyed) { + instrumentation.runOnMainSync { + if (!activity.isDestroyed) { + activity.finish() + } + } + } } @Test diff --git a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsSizeTest.kt b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsSizeTest.kt index 5b7c4ba6c018b..b397ecb105e73 100644 --- a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsSizeTest.kt +++ b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsSizeTest.kt @@ -36,14 +36,11 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.Insets as AndroidXInsets import androidx.core.view.DisplayCutoutCompat import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Before import org.junit.Rule @@ -57,29 +54,23 @@ class WindowInsetsSizeTest { private lateinit var insetsView: InsetsView - private lateinit var finishLatch: CountDownLatch - private val finishLatchGetter - get() = finishLatch - - private val observer = - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - finishLatchGetter.countDown() - } - } - @Before fun setup() { WindowInsetsHolder.setUseTestInsets(true) - finishLatch = CountDownLatch(1) - rule.runOnUiThread { rule.activity.lifecycle.addObserver(observer) } } @After fun teardown() { WindowInsetsHolder.setUseTestInsets(false) - rule.runOnUiThread { rule.activity.finish() } - assertThat(finishLatch.await(1, TimeUnit.SECONDS)).isTrue() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = rule.activity + while (!activity.isDestroyed) { + instrumentation.runOnMainSync { + if (!activity.isDestroyed) { + activity.finish() + } + } + } } @OptIn(ExperimentalLayoutApi::class) diff --git a/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/InsetsHelper.android.kt b/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/InsetsHelper.android.kt deleted file mode 100644 index 079d2672439bc..0000000000000 --- a/compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/InsetsHelper.android.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.foundation.layout - -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.DpRect -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.roundToIntRect - -/** - * Creates a new instance of [androidx.core.graphics.Insets] with the same bounds specified in the - * given [IntRect]. - */ -fun IntRect.toAndroidXInsets(): androidx.core.graphics.Insets = - androidx.core.graphics.Insets.of(left, top, right, bottom) - -/** - * Creates a new instance of [IntRect] with the same bounds specified in the given - * [androidx.core.graphics.Insets]. - */ -fun androidx.core.graphics.Insets.toComposeIntRect(): IntRect = IntRect(left, top, right, bottom) - -/** - * Converts the [DpRect] to [androidx.core.graphics.Insets] by using the [LocalDensity] and rounding - * to the nearest pixel values in each dimension. - */ -@Composable -fun DpRect.roundToAndroidXInsets(): androidx.core.graphics.Insets = - with(LocalDensity.current) { toRect() }.roundToIntRect().toAndroidXInsets() diff --git a/compose/foundation/foundation-layout/src/androidUnitTest/kotlin/androidx/compose/foundation/layout/InsetsHelperTest.kt b/compose/foundation/foundation-layout/src/androidUnitTest/kotlin/androidx/compose/foundation/layout/InsetsHelperTest.kt deleted file mode 100644 index 8c1ebbbc83705..0000000000000 --- a/compose/foundation/foundation-layout/src/androidUnitTest/kotlin/androidx/compose/foundation/layout/InsetsHelperTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.foundation.layout - -import androidx.compose.ui.unit.IntRect -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 - -@RunWith(JUnit4::class) -class InsetsHelperTest { - @Test - fun intRectToAndroidXInsetsConverts() { - assertEquals( - androidx.core.graphics.Insets.of(5, 6, 7, 8), - IntRect(5, 6, 7, 8).toAndroidXInsets(), - ) - } - - @Test - fun androidXInsetsToOntRectConverts() { - assertEquals( - IntRect(5, 6, 7, 8), - androidx.core.graphics.Insets.of(5, 6, 7, 8).toComposeIntRect(), - ) - } -} diff --git a/compose/foundation/foundation/api/current.ignore b/compose/foundation/foundation/api/current.ignore index 2763e69262b4d..5333980ba386f 100644 --- a/compose/foundation/foundation/api/current.ignore +++ b/compose/foundation/foundation/api/current.ignore @@ -23,5 +23,5 @@ ParameterNameChange: androidx.compose.foundation.gestures.DraggableAnchors#posit Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf -RemovedClass: androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt: - Removed class androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt +RemovedMethod: androidx.compose.foundation.lazy.grid.GridItemSpan#getCurrentLineSpan(): + Removed method androidx.compose.foundation.lazy.grid.GridItemSpan.getCurrentLineSpan() diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt index d8a163000e57a..30beb65cc2cc5 100644 --- a/compose/foundation/foundation/api/current.txt +++ b/compose/foundation/foundation/api/current.txt @@ -25,6 +25,10 @@ package androidx.compose.foundation { method @androidx.compose.runtime.Composable public static void AndroidExternalSurface(optional androidx.compose.ui.Modifier modifier, optional boolean isOpaque, optional long surfaceSize, optional int zOrder, optional boolean isSecure, kotlin.jvm.functions.Function1 onInit); } + public final class AndroidOverscroll_androidKt { + method @androidx.compose.runtime.Composable public static androidx.compose.foundation.OverscrollFactory rememberPlatformOverscrollFactory(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues glowDrawPadding); + } + public final class BackgroundKt { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier background(androidx.compose.ui.Modifier, androidx.compose.ui.graphics.Brush brush, optional androidx.compose.ui.graphics.Shape shape, optional @FloatRange(from=0.0, to=1.0) float alpha); method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier background(androidx.compose.ui.Modifier, long color, optional androidx.compose.ui.graphics.Shape shape); @@ -38,6 +42,7 @@ package androidx.compose.foundation { @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class BasicTooltipDefaults { method public androidx.compose.foundation.MutatorMutex getGlobalMutatorMutex(); property public final androidx.compose.foundation.MutatorMutex GlobalMutatorMutex; + property public static final long TooltipDuration; field public static final androidx.compose.foundation.BasicTooltipDefaults INSTANCE; field public static final long TooltipDuration = 1500L; // 0x5dcL } @@ -90,7 +95,6 @@ package androidx.compose.foundation { } public final class ClickableKt { - method public static androidx.compose.foundation.CombinedClickableNode CombinedClickableNode(kotlin.jvm.functions.Function0 onClick, String? onLongClickLabel, kotlin.jvm.functions.Function0? onLongClick, kotlin.jvm.functions.Function0? onDoubleClick, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.IndicationNodeFactory? indicationNodeFactory, boolean enabled, String? onClickLabel, androidx.compose.ui.semantics.Role? role); method public static androidx.compose.ui.Modifier clickable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0 onClick); method public static androidx.compose.ui.Modifier clickable(androidx.compose.ui.Modifier, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0 onClick); method public static androidx.compose.ui.Modifier combinedClickable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, optional String? onLongClickLabel, optional kotlin.jvm.functions.Function0? onLongClick, optional kotlin.jvm.functions.Function0? onDoubleClick, optional boolean hapticFeedbackEnabled, kotlin.jvm.functions.Function0 onClick); @@ -103,13 +107,11 @@ package androidx.compose.foundation { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier clipScrollableContainer(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.Orientation orientation); } - public sealed interface CombinedClickableNode extends androidx.compose.ui.node.PointerInputModifierNode { - method public void update(kotlin.jvm.functions.Function0 onClick, String? onLongClickLabel, kotlin.jvm.functions.Function0? onLongClick, kotlin.jvm.functions.Function0? onDoubleClick, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.IndicationNodeFactory? indicationNodeFactory, boolean enabled, String? onClickLabel, androidx.compose.ui.semantics.Role? role); - } - @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class ComposeFoundationFlags { + property public final boolean DragGesturePickUpEnabled; + property public final boolean NewNestedFlingPropagationEnabled; + property public final boolean RemoveBasicTextGraphicsLayerEnabled; field public static boolean DragGesturePickUpEnabled; - field public static boolean DraggableAddDownEventFixEnabled; field public static final androidx.compose.foundation.ComposeFoundationFlags INSTANCE; field public static boolean NewNestedFlingPropagationEnabled; field public static boolean RemoveBasicTextGraphicsLayerEnabled; @@ -219,31 +221,44 @@ package androidx.compose.foundation { method public inline boolean tryMutate(kotlin.jvm.functions.Function0 block); } - @androidx.compose.runtime.Stable public final class OverscrollConfiguration { - ctor public OverscrollConfiguration(); - ctor public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding); - method public androidx.compose.foundation.layout.PaddingValues getDrawPadding(); - method public long getGlowColor(); - property public final androidx.compose.foundation.layout.PaddingValues drawPadding; - property public final long glowColor; + @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class OverscrollConfiguration { + ctor @Deprecated public OverscrollConfiguration(); + ctor @Deprecated public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding); + method @Deprecated public androidx.compose.foundation.layout.PaddingValues getDrawPadding(); + method @Deprecated public long getGlowColor(); + property @Deprecated public final androidx.compose.foundation.layout.PaddingValues drawPadding; + property @Deprecated public final long glowColor; } public final class OverscrollConfiguration_androidKt { - method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalOverscrollConfiguration(); - property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalOverscrollConfiguration; + method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalOverscrollConfiguration(); + property @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalOverscrollConfiguration; } @androidx.compose.runtime.Stable public interface OverscrollEffect { method public suspend Object? applyToFling(long velocity, kotlin.jvm.functions.Function2,? extends java.lang.Object?> performFling, kotlin.coroutines.Continuation); method public long applyToScroll(long delta, int source, kotlin.jvm.functions.Function1 performScroll); - method public androidx.compose.ui.Modifier getEffectModifier(); + method @Deprecated public default androidx.compose.ui.Modifier getEffectModifier(); + method public default androidx.compose.ui.node.DelegatableNode getNode(); method public boolean isInProgress(); - property public abstract androidx.compose.ui.Modifier effectModifier; + property @Deprecated public default androidx.compose.ui.Modifier effectModifier; property public abstract boolean isInProgress; + property public default androidx.compose.ui.node.DelegatableNode node; + } + + public interface OverscrollFactory { + method public androidx.compose.foundation.OverscrollEffect createOverscrollEffect(); + method public boolean equals(Object? other); + method public int hashCode(); } public final class OverscrollKt { - method public static androidx.compose.ui.Modifier overscroll(androidx.compose.ui.Modifier, androidx.compose.foundation.OverscrollEffect overscrollEffect); + method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalOverscrollFactory(); + method public static androidx.compose.ui.Modifier overscroll(androidx.compose.ui.Modifier, androidx.compose.foundation.OverscrollEffect? overscrollEffect); + method @androidx.compose.runtime.Composable public static androidx.compose.foundation.OverscrollEffect? rememberOverscrollEffect(); + method @androidx.compose.runtime.Stable public static androidx.compose.foundation.OverscrollEffect withoutDrawing(androidx.compose.foundation.OverscrollEffect); + method @androidx.compose.runtime.Stable public static androidx.compose.foundation.OverscrollEffect withoutEventHandling(androidx.compose.foundation.OverscrollEffect); + property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalOverscrollFactory; } public final class PreferKeepClear_androidKt { @@ -257,8 +272,10 @@ package androidx.compose.foundation { } public final class ScrollKt { + method public static androidx.compose.ui.Modifier horizontalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); method public static androidx.compose.ui.Modifier horizontalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); method @androidx.compose.runtime.Composable public static androidx.compose.foundation.ScrollState rememberScrollState(optional int initial); + method public static androidx.compose.ui.Modifier verticalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); method public static androidx.compose.ui.Modifier verticalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); } @@ -431,8 +448,10 @@ package androidx.compose.foundation.gestures { } @androidx.compose.runtime.Stable public final class AnchoredDraggableState { - ctor public AnchoredDraggableState(T initialValue, androidx.compose.foundation.gestures.DraggableAnchors anchors, optional kotlin.jvm.functions.Function1 confirmValueChange); - ctor public AnchoredDraggableState(T initialValue, optional kotlin.jvm.functions.Function1 confirmValueChange); + ctor public AnchoredDraggableState(T initialValue); + ctor public AnchoredDraggableState(T initialValue, androidx.compose.foundation.gestures.DraggableAnchors anchors); + ctor @Deprecated public AnchoredDraggableState(T initialValue, androidx.compose.foundation.gestures.DraggableAnchors anchors, optional kotlin.jvm.functions.Function1 confirmValueChange); + ctor @Deprecated public AnchoredDraggableState(T initialValue, kotlin.jvm.functions.Function1 confirmValueChange); method public suspend Object? anchoredDrag(optional androidx.compose.foundation.MutatePriority dragPriority, kotlin.jvm.functions.Function3,? super kotlin.coroutines.Continuation,? extends java.lang.Object?> block, kotlin.coroutines.Continuation); method public suspend Object? anchoredDrag(T targetValue, optional androidx.compose.foundation.MutatePriority dragPriority, kotlin.jvm.functions.Function4,? super T,? super kotlin.coroutines.Continuation,? extends java.lang.Object?> block, kotlin.coroutines.Continuation); method public float dispatchRawDelta(float delta); @@ -467,8 +486,9 @@ package androidx.compose.foundation.gestures { } public static final class AnchoredDraggableState.Companion { + method public androidx.compose.runtime.saveable.Saver,T> Saver(); method @Deprecated public androidx.compose.runtime.saveable.Saver,T> Saver(androidx.compose.animation.core.AnimationSpec snapAnimationSpec, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, kotlin.jvm.functions.Function1 positionalThreshold, kotlin.jvm.functions.Function0 velocityThreshold, optional kotlin.jvm.functions.Function1 confirmValueChange); - method public androidx.compose.runtime.saveable.Saver,T> Saver(optional kotlin.jvm.functions.Function1 confirmValueChange); + method @Deprecated public androidx.compose.runtime.saveable.Saver,T> Saver(optional kotlin.jvm.functions.Function1 confirmValueChange); } @androidx.compose.runtime.Stable public interface BringIntoViewSpec { @@ -590,7 +610,7 @@ package androidx.compose.foundation.gestures { public final class ScrollableDefaults { method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior(); - method @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect(); + method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect(); method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling); field public static final androidx.compose.foundation.gestures.ScrollableDefaults INSTANCE; } @@ -824,9 +844,11 @@ package androidx.compose.foundation.interaction { package androidx.compose.foundation.lazy { public final class LazyDslKt { - method @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method @Deprecated @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, kotlin.jvm.functions.Function1 content); - method @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method @Deprecated @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, kotlin.jvm.functions.Function1 content); method public static inline void items(androidx.compose.foundation.lazy.LazyListScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function1 contentType, kotlin.jvm.functions.Function2 itemContent); method @Deprecated public static inline void items(androidx.compose.foundation.lazy.LazyListScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, kotlin.jvm.functions.Function2 itemContent); @@ -973,13 +995,14 @@ package androidx.compose.foundation.lazy.grid { } @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class GridItemSpan { - method public int getCurrentLineSpan(); property public final int currentLineSpan; } public final class LazyGridDslKt { - method @androidx.compose.runtime.Composable public static void LazyHorizontalGrid(androidx.compose.foundation.lazy.grid.GridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); - method @androidx.compose.runtime.Composable public static void LazyVerticalGrid(androidx.compose.foundation.lazy.grid.GridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyHorizontalGrid(androidx.compose.foundation.lazy.grid.GridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyHorizontalGrid(androidx.compose.foundation.lazy.grid.GridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyVerticalGrid(androidx.compose.foundation.lazy.grid.GridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyVerticalGrid(androidx.compose.foundation.lazy.grid.GridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method public static inline void items(androidx.compose.foundation.lazy.grid.LazyGridScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function2? span, optional kotlin.jvm.functions.Function1 contentType, kotlin.jvm.functions.Function2 itemContent); method public static inline void items(androidx.compose.foundation.lazy.grid.LazyGridScope, T[] items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function2? span, optional kotlin.jvm.functions.Function1 contentType, kotlin.jvm.functions.Function2 itemContent); method public static inline void itemsIndexed(androidx.compose.foundation.lazy.grid.LazyGridScope, java.util.List items, optional kotlin.jvm.functions.Function2? key, optional kotlin.jvm.functions.Function3? span, optional kotlin.jvm.functions.Function2 contentType, kotlin.jvm.functions.Function3 itemContent); @@ -1009,6 +1032,8 @@ package androidx.compose.foundation.lazy.grid { } public static final class LazyGridItemInfo.Companion { + property public static final int UnknownColumn; + property public static final int UnknownRow; field public static final int UnknownColumn = -1; // 0xffffffff field public static final int UnknownRow = -1; // 0xffffffff } @@ -1256,8 +1281,10 @@ package androidx.compose.foundation.lazy.layout { package androidx.compose.foundation.lazy.staggeredgrid { public final class LazyStaggeredGridDslKt { - method @androidx.compose.runtime.Composable public static void LazyHorizontalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional float horizontalItemSpacing, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); - method @androidx.compose.runtime.Composable public static void LazyVerticalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional float verticalItemSpacing, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyHorizontalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional float horizontalItemSpacing, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyHorizontalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional float horizontalItemSpacing, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyVerticalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional float verticalItemSpacing, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyVerticalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional float verticalItemSpacing, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method public static inline void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function1 contentType, optional kotlin.jvm.functions.Function1? span, kotlin.jvm.functions.Function2 itemContent); method public static inline void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, T[] items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function1 contentType, optional kotlin.jvm.functions.Function1? span, kotlin.jvm.functions.Function2 itemContent); method public static inline void itemsIndexed(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List items, optional kotlin.jvm.functions.Function2? key, optional kotlin.jvm.functions.Function2 contentType, optional kotlin.jvm.functions.Function2? span, kotlin.jvm.functions.Function3 itemContent); @@ -1408,13 +1435,16 @@ package androidx.compose.foundation.pager { public final class PagerDefaults { method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.foundation.pager.PagerSnapDistance pagerSnapDistance, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec snapAnimationSpec, optional @FloatRange(from=0.0, to=1.0) float snapPositionalThreshold); method @androidx.compose.runtime.Composable public androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection(androidx.compose.foundation.pager.PagerState state, androidx.compose.foundation.gestures.Orientation orientation); + property public static final int BeyondViewportPageCount; field public static final int BeyondViewportPageCount = 0; // 0x0 field public static final androidx.compose.foundation.pager.PagerDefaults INSTANCE; } public final class PagerKt { - method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); - method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); + method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function2 pageContent); + method @Deprecated @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); + method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function2 pageContent); + method @Deprecated @androidx.compose.runtime.Composable public static void VerticalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); } public sealed interface PagerLayoutInfo { @@ -1603,7 +1633,7 @@ package androidx.compose.foundation.shape { method @androidx.compose.runtime.Stable public static androidx.compose.foundation.shape.CornerSize CornerSize(float size); method @androidx.compose.runtime.Stable public static androidx.compose.foundation.shape.CornerSize CornerSize(@IntRange(from=0L, to=100L) int percent); method public static androidx.compose.foundation.shape.CornerSize getZeroCornerSize(); - property public static final androidx.compose.foundation.shape.CornerSize ZeroCornerSize; + property @androidx.compose.runtime.Stable public static final androidx.compose.foundation.shape.CornerSize ZeroCornerSize; } public final class CutCornerShape extends androidx.compose.foundation.shape.CornerBasedShape { @@ -1649,6 +1679,39 @@ package androidx.compose.foundation.shape { package androidx.compose.foundation.text { + public sealed interface AutoSize { + field public static final androidx.compose.foundation.text.AutoSize.Companion Companion; + } + + public static final class AutoSize.Companion { + method public androidx.compose.foundation.text.AutoSize StepBased(optional long minFontSize, optional long maxFontSize, optional long stepSize); + } + + public final class AutoSizeDefaults { + method public long getMaxFontSize(); + method public long getMinFontSize(); + property public final long MaxFontSize; + property public final long MinFontSize; + field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE; + } + + @kotlin.jvm.JvmInline public final value class AutofillHighlight { + ctor public AutofillHighlight(long autofillHighlightColor); + method public long getAutofillHighlightColor(); + property public final long autofillHighlightColor; + field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion; + } + + public static final class AutofillHighlight.Companion { + method public long getDefault(); + property public final long Default; + } + + public final class AutofillHighlightKt { + method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofillHighlight(); + property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofillHighlight; + } + public final class BasicSecureTextFieldKt { method @Deprecated @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional kotlin.jvm.functions.Function2,kotlin.Unit>? onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.text.input.TextFieldDecorator? decorator, optional int textObfuscationMode, optional char textObfuscationCharacter); method @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional kotlin.jvm.functions.Function2,kotlin.Unit>? onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.text.input.TextFieldDecorator? decorator, optional int textObfuscationMode, optional char textObfuscationCharacter); @@ -1664,11 +1727,13 @@ package androidx.compose.foundation.text { public final class BasicTextKt { method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent); - method @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent, optional androidx.compose.ui.graphics.ColorProducer? color); + method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent, optional androidx.compose.ui.graphics.ColorProducer? color); + method @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent, optional androidx.compose.ui.graphics.ColorProducer? color, optional androidx.compose.foundation.text.AutoSize? autoSize); method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional java.util.Map inlineContent); method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines); method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines); - method @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional androidx.compose.ui.graphics.ColorProducer? color); + method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional androidx.compose.ui.graphics.ColorProducer? color); + method @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional androidx.compose.ui.graphics.ColorProducer? color, optional androidx.compose.foundation.text.AutoSize? autoSize); } public final class ClickableTextKt { @@ -1714,7 +1779,7 @@ package androidx.compose.foundation.text { public static final class KeyboardActions.Companion { method public androidx.compose.foundation.text.KeyboardActions getDefault(); - property public final androidx.compose.foundation.text.KeyboardActions Default; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.text.KeyboardActions Default; } public final class KeyboardActionsKt { @@ -1748,14 +1813,14 @@ package androidx.compose.foundation.text { property public final int imeAction; property public final int keyboardType; property public final androidx.compose.ui.text.input.PlatformImeOptions? platformImeOptions; - property public boolean shouldShowKeyboardOnFocus; + property @Deprecated public boolean shouldShowKeyboardOnFocus; property public final Boolean? showKeyboardOnFocus; field public static final androidx.compose.foundation.text.KeyboardOptions.Companion Companion; } public static final class KeyboardOptions.Companion { method public androidx.compose.foundation.text.KeyboardOptions getDefault(); - property public final androidx.compose.foundation.text.KeyboardOptions Default; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.text.KeyboardOptions Default; } } diff --git a/compose/foundation/foundation/api/desktop/foundation.api b/compose/foundation/foundation/api/desktop/foundation.api index 976efa4e7edd4..d97b500479c11 100644 --- a/compose/foundation/foundation/api/desktop/foundation.api +++ b/compose/foundation/foundation/api/desktop/foundation.api @@ -73,7 +73,6 @@ public final class androidx/compose/foundation/CheckScrollableContainerConstrain } public final class androidx/compose/foundation/ClickableKt { - public static final fun CombinedClickableNode-nSzSaCc (Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/IndicationNodeFactory;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;)Landroidx/compose/foundation/CombinedClickableNode; public static final fun clickable-O2vRcR0 (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; public static synthetic fun clickable-O2vRcR0$default (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; public static final fun clickable-XHw0xAI (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; @@ -97,14 +96,9 @@ public final class androidx/compose/foundation/ClipScrollableContainerKt { public static final fun clipScrollableContainer (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/gestures/Orientation;)Landroidx/compose/ui/Modifier; } -public abstract interface class androidx/compose/foundation/CombinedClickableNode : androidx/compose/ui/node/PointerInputModifierNode { - public abstract fun update-nSzSaCc (Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/IndicationNodeFactory;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;)V -} - public final class androidx/compose/foundation/ComposeFoundationFlags { public static final field $stable I public static field DragGesturePickUpEnabled Z - public static field DraggableAddDownEventFixEnabled Z public static final field INSTANCE Landroidx/compose/foundation/ComposeFoundationFlags; public static field NewNestedFlingPropagationEnabled Z public static field RemoveBasicTextGraphicsLayerEnabled Z @@ -309,12 +303,23 @@ public final class androidx/compose/foundation/OnClick_skikoKt { public abstract interface class androidx/compose/foundation/OverscrollEffect { public abstract fun applyToFling-BMRW4eQ (JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun applyToScroll-Rhakbz0 (JILkotlin/jvm/functions/Function1;)J - public abstract fun getEffectModifier ()Landroidx/compose/ui/Modifier; + public fun getEffectModifier ()Landroidx/compose/ui/Modifier; + public fun getNode ()Landroidx/compose/ui/node/DelegatableNode; public abstract fun isInProgress ()Z } +public abstract interface class androidx/compose/foundation/OverscrollFactory { + public abstract fun createOverscrollEffect ()Landroidx/compose/foundation/OverscrollEffect; + public abstract fun equals (Ljava/lang/Object;)Z + public abstract fun hashCode ()I +} + public final class androidx/compose/foundation/OverscrollKt { + public static final fun getLocalOverscrollFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun overscroll (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/OverscrollEffect;)Landroidx/compose/ui/Modifier; + public static final fun rememberOverscrollEffect (Landroidx/compose/runtime/Composer;I)Landroidx/compose/foundation/OverscrollEffect; + public static final fun withoutDrawing (Landroidx/compose/foundation/OverscrollEffect;)Landroidx/compose/foundation/OverscrollEffect; + public static final fun withoutEventHandling (Landroidx/compose/foundation/OverscrollEffect;)Landroidx/compose/foundation/OverscrollEffect; } public abstract interface class androidx/compose/foundation/PointerMatcher { @@ -342,10 +347,14 @@ public final class androidx/compose/foundation/ProgressSemanticsKt { } public final class androidx/compose/foundation/ScrollKt { + public static final fun horizontalScroll (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;Landroidx/compose/foundation/OverscrollEffect;ZLandroidx/compose/foundation/gestures/FlingBehavior;Z)Landroidx/compose/ui/Modifier; public static final fun horizontalScroll (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;ZLandroidx/compose/foundation/gestures/FlingBehavior;Z)Landroidx/compose/ui/Modifier; + public static synthetic fun horizontalScroll$default (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;Landroidx/compose/foundation/OverscrollEffect;ZLandroidx/compose/foundation/gestures/FlingBehavior;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; public static synthetic fun horizontalScroll$default (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;ZLandroidx/compose/foundation/gestures/FlingBehavior;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; public static final fun rememberScrollState (ILandroidx/compose/runtime/Composer;II)Landroidx/compose/foundation/ScrollState; + public static final fun verticalScroll (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;Landroidx/compose/foundation/OverscrollEffect;ZLandroidx/compose/foundation/gestures/FlingBehavior;Z)Landroidx/compose/ui/Modifier; public static final fun verticalScroll (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;ZLandroidx/compose/foundation/gestures/FlingBehavior;Z)Landroidx/compose/ui/Modifier; + public static synthetic fun verticalScroll$default (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;Landroidx/compose/foundation/OverscrollEffect;ZLandroidx/compose/foundation/gestures/FlingBehavior;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; public static synthetic fun verticalScroll$default (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;ZLandroidx/compose/foundation/gestures/FlingBehavior;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; } @@ -557,10 +566,11 @@ public final class androidx/compose/foundation/gestures/AnchoredDraggableKt { public final class androidx/compose/foundation/gestures/AnchoredDraggableState { public static final field $stable I public static final field Companion Landroidx/compose/foundation/gestures/AnchoredDraggableState$Companion; + public fun (Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Landroidx/compose/foundation/gestures/DraggableAnchors;)V public fun (Ljava/lang/Object;Landroidx/compose/foundation/gestures/DraggableAnchors;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/Object;Landroidx/compose/foundation/gestures/DraggableAnchors;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun anchoredDrag (Landroidx/compose/foundation/MutatePriority;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun anchoredDrag (Ljava/lang/Object;Landroidx/compose/foundation/MutatePriority;Lkotlin/jvm/functions/Function4;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun anchoredDrag$default (Landroidx/compose/foundation/gestures/AnchoredDraggableState;Landroidx/compose/foundation/MutatePriority;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -585,6 +595,7 @@ public final class androidx/compose/foundation/gestures/AnchoredDraggableState { } public final class androidx/compose/foundation/gestures/AnchoredDraggableState$Companion { + public final fun Saver ()Landroidx/compose/runtime/saveable/Saver; public final fun Saver (Landroidx/compose/animation/core/AnimationSpec;Landroidx/compose/animation/core/DecayAnimationSpec;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Landroidx/compose/runtime/saveable/Saver; public final fun Saver (Lkotlin/jvm/functions/Function1;)Landroidx/compose/runtime/saveable/Saver; public static synthetic fun Saver$default (Landroidx/compose/foundation/gestures/AnchoredDraggableState$Companion;Landroidx/compose/animation/core/AnimationSpec;Landroidx/compose/animation/core/DecayAnimationSpec;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Landroidx/compose/runtime/saveable/Saver; @@ -1022,9 +1033,11 @@ public final class androidx/compose/foundation/interaction/PressInteractionKt { public final class androidx/compose/foundation/lazy/LazyDslKt { public static final synthetic fun LazyColumn (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/ui/Alignment$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V - public static final fun LazyColumn (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/ui/Alignment$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun LazyColumn (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/ui/Alignment$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLandroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun LazyColumn (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/ui/Alignment$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final synthetic fun LazyRow (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/ui/Alignment$Vertical;Landroidx/compose/foundation/gestures/FlingBehavior;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V - public static final fun LazyRow (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/ui/Alignment$Vertical;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun LazyRow (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/ui/Alignment$Vertical;Landroidx/compose/foundation/gestures/FlingBehavior;ZLandroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun LazyRow (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/ui/Alignment$Vertical;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun items (Landroidx/compose/foundation/lazy/LazyListScope;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V public static final synthetic fun items (Landroidx/compose/foundation/lazy/LazyListScope;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V public static final fun items (Landroidx/compose/foundation/lazy/LazyListScope;[Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V @@ -1222,8 +1235,10 @@ public final class androidx/compose/foundation/lazy/grid/GridItemSpan { } public final class androidx/compose/foundation/lazy/grid/LazyGridDslKt { - public static final fun LazyHorizontalGrid (Landroidx/compose/foundation/lazy/grid/GridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/grid/LazyGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V - public static final fun LazyVerticalGrid (Landroidx/compose/foundation/lazy/grid/GridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/grid/LazyGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun LazyHorizontalGrid (Landroidx/compose/foundation/lazy/grid/GridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/grid/LazyGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/gestures/FlingBehavior;ZLandroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun LazyHorizontalGrid (Landroidx/compose/foundation/lazy/grid/GridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/grid/LazyGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun LazyVerticalGrid (Landroidx/compose/foundation/lazy/grid/GridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/grid/LazyGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLandroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun LazyVerticalGrid (Landroidx/compose/foundation/lazy/grid/GridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/grid/LazyGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun items (Landroidx/compose/foundation/lazy/grid/LazyGridScope;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V public static final fun items (Landroidx/compose/foundation/lazy/grid/LazyGridScope;[Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V public static synthetic fun items$default (Landroidx/compose/foundation/lazy/grid/LazyGridScope;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V @@ -1506,8 +1521,10 @@ public abstract interface class androidx/compose/foundation/lazy/layout/Prefetch } public final class androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDslKt { - public static final fun LazyHorizontalStaggeredGrid-cJHQLPU (Landroidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;FLandroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V - public static final fun LazyVerticalStaggeredGrid-zadm560 (Landroidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState;Landroidx/compose/foundation/layout/PaddingValues;ZFLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun LazyHorizontalStaggeredGrid-121YqSk (Landroidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;FLandroidx/compose/foundation/gestures/FlingBehavior;ZLandroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun LazyHorizontalStaggeredGrid-cJHQLPU (Landroidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState;Landroidx/compose/foundation/layout/PaddingValues;ZLandroidx/compose/foundation/layout/Arrangement$Vertical;FLandroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun LazyVerticalStaggeredGrid-6qCrX9Q (Landroidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState;Landroidx/compose/foundation/layout/PaddingValues;ZFLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLandroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun LazyVerticalStaggeredGrid-zadm560 (Landroidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState;Landroidx/compose/foundation/layout/PaddingValues;ZFLandroidx/compose/foundation/layout/Arrangement$Horizontal;Landroidx/compose/foundation/gestures/FlingBehavior;ZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun items (Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScope;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V public static final fun items (Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScope;[Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V public static synthetic fun items$default (Landroidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScope;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V @@ -1659,8 +1676,10 @@ public final class androidx/compose/foundation/pager/PagerDefaults { } public final class androidx/compose/foundation/pager/PagerKt { - public static final fun HorizontalPager-oI3XNZo (Landroidx/compose/foundation/pager/PagerState;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/pager/PageSize;IFLandroidx/compose/ui/Alignment$Vertical;Landroidx/compose/foundation/gestures/TargetedFlingBehavior;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/input/nestedscroll/NestedScrollConnection;Landroidx/compose/foundation/gestures/snapping/SnapPosition;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V - public static final fun VerticalPager-oI3XNZo (Landroidx/compose/foundation/pager/PagerState;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/pager/PageSize;IFLandroidx/compose/ui/Alignment$Horizontal;Landroidx/compose/foundation/gestures/TargetedFlingBehavior;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/input/nestedscroll/NestedScrollConnection;Landroidx/compose/foundation/gestures/snapping/SnapPosition;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final fun HorizontalPager--8jOkeI (Landroidx/compose/foundation/pager/PagerState;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/pager/PageSize;IFLandroidx/compose/ui/Alignment$Vertical;Landroidx/compose/foundation/gestures/TargetedFlingBehavior;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/input/nestedscroll/NestedScrollConnection;Landroidx/compose/foundation/gestures/snapping/SnapPosition;Landroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun HorizontalPager-oI3XNZo (Landroidx/compose/foundation/pager/PagerState;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/pager/PageSize;IFLandroidx/compose/ui/Alignment$Vertical;Landroidx/compose/foundation/gestures/TargetedFlingBehavior;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/input/nestedscroll/NestedScrollConnection;Landroidx/compose/foundation/gestures/snapping/SnapPosition;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final fun VerticalPager--8jOkeI (Landroidx/compose/foundation/pager/PagerState;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/pager/PageSize;IFLandroidx/compose/ui/Alignment$Horizontal;Landroidx/compose/foundation/gestures/TargetedFlingBehavior;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/input/nestedscroll/NestedScrollConnection;Landroidx/compose/foundation/gestures/snapping/SnapPosition;Landroidx/compose/foundation/OverscrollEffect;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun VerticalPager-oI3XNZo (Landroidx/compose/foundation/pager/PagerState;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/pager/PageSize;IFLandroidx/compose/ui/Alignment$Horizontal;Landroidx/compose/foundation/gestures/TargetedFlingBehavior;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/input/nestedscroll/NestedScrollConnection;Landroidx/compose/foundation/gestures/snapping/SnapPosition;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V } public abstract interface class androidx/compose/foundation/pager/PagerLayoutInfo { @@ -1904,6 +1923,45 @@ public final class androidx/compose/foundation/shape/RoundedCornerShapeKt { public static final fun getCircleShape ()Landroidx/compose/foundation/shape/RoundedCornerShape; } +public abstract interface class androidx/compose/foundation/text/AutoSize { + public static final field Companion Landroidx/compose/foundation/text/AutoSize$Companion; +} + +public final class androidx/compose/foundation/text/AutoSize$Companion { + public final fun StepBased-vU-0ePk (JJJ)Landroidx/compose/foundation/text/AutoSize; + public static synthetic fun StepBased-vU-0ePk$default (Landroidx/compose/foundation/text/AutoSize$Companion;JJJILjava/lang/Object;)Landroidx/compose/foundation/text/AutoSize; +} + +public final class androidx/compose/foundation/text/AutoSizeDefaults { + public static final field $stable I + public static final field INSTANCE Landroidx/compose/foundation/text/AutoSizeDefaults; + public final fun getMaxFontSize-XSAIIZE ()J + public final fun getMinFontSize-XSAIIZE ()J +} + +public final class androidx/compose/foundation/text/AutofillHighlight { + public static final field Companion Landroidx/compose/foundation/text/AutofillHighlight$Companion; + public static final synthetic fun box-impl (J)Landroidx/compose/foundation/text/AutofillHighlight; + public static fun constructor-impl (J)J + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (JLjava/lang/Object;)Z + public static final fun equals-impl0 (JJ)Z + public final fun getAutofillHighlightColor-0d7_KjU ()J + public fun hashCode ()I + public static fun hashCode-impl (J)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (J)Ljava/lang/String; + public final synthetic fun unbox-impl ()J +} + +public final class androidx/compose/foundation/text/AutofillHighlight$Companion { + public final fun getDefault-q3uy-Y0 ()J +} + +public final class androidx/compose/foundation/text/AutofillHighlightKt { + public static final fun getLocalAutofillHighlight ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + public final class androidx/compose/foundation/text/BasicSecureTextFieldKt { public static final synthetic fun BasicSecureTextField-Jb9bMDk (Landroidx/compose/foundation/text/input/TextFieldState;Landroidx/compose/ui/Modifier;ZLandroidx/compose/foundation/text/input/InputTransformation;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/foundation/text/KeyboardOptions;Landroidx/compose/foundation/text/input/KeyboardActionHandler;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/ui/graphics/Brush;Landroidx/compose/foundation/text/input/TextFieldDecorator;ICLandroidx/compose/runtime/Composer;III)V public static final fun BasicSecureTextField-egD4TGM (Landroidx/compose/foundation/text/input/TextFieldState;Landroidx/compose/ui/Modifier;ZZLandroidx/compose/foundation/text/input/InputTransformation;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/foundation/text/KeyboardOptions;Landroidx/compose/foundation/text/input/KeyboardActionHandler;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/ui/graphics/Brush;Landroidx/compose/foundation/text/input/TextFieldDecorator;ICLandroidx/compose/runtime/Composer;III)V @@ -1926,9 +1984,11 @@ public final class androidx/compose/foundation/text/BasicTextKt { public static final synthetic fun BasicText-4YKlhWE (Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZILjava/util/Map;Landroidx/compose/runtime/Composer;II)V public static final synthetic fun BasicText-4YKlhWE (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILandroidx/compose/runtime/Composer;II)V public static final synthetic fun BasicText-BpD7jsM (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZILandroidx/compose/runtime/Composer;II)V - public static final fun BasicText-RWo7tUw (Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILjava/util/Map;Landroidx/compose/ui/graphics/ColorProducer;Landroidx/compose/runtime/Composer;II)V + public static final fun BasicText-CL7eQgs (Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILjava/util/Map;Landroidx/compose/ui/graphics/ColorProducer;Landroidx/compose/foundation/text/AutoSize;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun BasicText-RWo7tUw (Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILjava/util/Map;Landroidx/compose/ui/graphics/ColorProducer;Landroidx/compose/runtime/Composer;II)V + public static final fun BasicText-RWo7tUw (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILandroidx/compose/ui/graphics/ColorProducer;Landroidx/compose/foundation/text/AutoSize;Landroidx/compose/runtime/Composer;II)V public static final synthetic fun BasicText-VhcvRP8 (Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILjava/util/Map;Landroidx/compose/runtime/Composer;II)V - public static final fun BasicText-VhcvRP8 (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILandroidx/compose/ui/graphics/ColorProducer;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun BasicText-VhcvRP8 (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;IZIILandroidx/compose/ui/graphics/ColorProducer;Landroidx/compose/runtime/Composer;II)V } public final class androidx/compose/foundation/text/ClickableTextKt { diff --git a/compose/foundation/foundation/api/restricted_current.ignore b/compose/foundation/foundation/api/restricted_current.ignore index 2763e69262b4d..5333980ba386f 100644 --- a/compose/foundation/foundation/api/restricted_current.ignore +++ b/compose/foundation/foundation/api/restricted_current.ignore @@ -23,5 +23,5 @@ ParameterNameChange: androidx.compose.foundation.gestures.DraggableAnchors#posit Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf -RemovedClass: androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt: - Removed class androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt +RemovedMethod: androidx.compose.foundation.lazy.grid.GridItemSpan#getCurrentLineSpan(): + Removed method androidx.compose.foundation.lazy.grid.GridItemSpan.getCurrentLineSpan() diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt index ee173f9fec321..4e11e6c938c3e 100644 --- a/compose/foundation/foundation/api/restricted_current.txt +++ b/compose/foundation/foundation/api/restricted_current.txt @@ -25,6 +25,10 @@ package androidx.compose.foundation { method @androidx.compose.runtime.Composable public static void AndroidExternalSurface(optional androidx.compose.ui.Modifier modifier, optional boolean isOpaque, optional long surfaceSize, optional int zOrder, optional boolean isSecure, kotlin.jvm.functions.Function1 onInit); } + public final class AndroidOverscroll_androidKt { + method @androidx.compose.runtime.Composable public static androidx.compose.foundation.OverscrollFactory rememberPlatformOverscrollFactory(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues glowDrawPadding); + } + public final class BackgroundKt { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier background(androidx.compose.ui.Modifier, androidx.compose.ui.graphics.Brush brush, optional androidx.compose.ui.graphics.Shape shape, optional @FloatRange(from=0.0, to=1.0) float alpha); method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier background(androidx.compose.ui.Modifier, long color, optional androidx.compose.ui.graphics.Shape shape); @@ -38,6 +42,7 @@ package androidx.compose.foundation { @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class BasicTooltipDefaults { method public androidx.compose.foundation.MutatorMutex getGlobalMutatorMutex(); property public final androidx.compose.foundation.MutatorMutex GlobalMutatorMutex; + property public static final long TooltipDuration; field public static final androidx.compose.foundation.BasicTooltipDefaults INSTANCE; field public static final long TooltipDuration = 1500L; // 0x5dcL } @@ -90,7 +95,6 @@ package androidx.compose.foundation { } public final class ClickableKt { - method public static androidx.compose.foundation.CombinedClickableNode CombinedClickableNode(kotlin.jvm.functions.Function0 onClick, String? onLongClickLabel, kotlin.jvm.functions.Function0? onLongClick, kotlin.jvm.functions.Function0? onDoubleClick, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.IndicationNodeFactory? indicationNodeFactory, boolean enabled, String? onClickLabel, androidx.compose.ui.semantics.Role? role); method public static androidx.compose.ui.Modifier clickable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0 onClick); method public static androidx.compose.ui.Modifier clickable(androidx.compose.ui.Modifier, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0 onClick); method public static androidx.compose.ui.Modifier combinedClickable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, optional String? onLongClickLabel, optional kotlin.jvm.functions.Function0? onLongClick, optional kotlin.jvm.functions.Function0? onDoubleClick, optional boolean hapticFeedbackEnabled, kotlin.jvm.functions.Function0 onClick); @@ -103,13 +107,11 @@ package androidx.compose.foundation { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier clipScrollableContainer(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.Orientation orientation); } - public sealed interface CombinedClickableNode extends androidx.compose.ui.node.PointerInputModifierNode { - method public void update(kotlin.jvm.functions.Function0 onClick, String? onLongClickLabel, kotlin.jvm.functions.Function0? onLongClick, kotlin.jvm.functions.Function0? onDoubleClick, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.IndicationNodeFactory? indicationNodeFactory, boolean enabled, String? onClickLabel, androidx.compose.ui.semantics.Role? role); - } - @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class ComposeFoundationFlags { + property public final boolean DragGesturePickUpEnabled; + property public final boolean NewNestedFlingPropagationEnabled; + property public final boolean RemoveBasicTextGraphicsLayerEnabled; field public static boolean DragGesturePickUpEnabled; - field public static boolean DraggableAddDownEventFixEnabled; field public static final androidx.compose.foundation.ComposeFoundationFlags INSTANCE; field public static boolean NewNestedFlingPropagationEnabled; field public static boolean RemoveBasicTextGraphicsLayerEnabled; @@ -221,31 +223,44 @@ package androidx.compose.foundation { method @kotlin.PublishedApi internal void unlock(); } - @androidx.compose.runtime.Stable public final class OverscrollConfiguration { - ctor public OverscrollConfiguration(); - ctor public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding); - method public androidx.compose.foundation.layout.PaddingValues getDrawPadding(); - method public long getGlowColor(); - property public final androidx.compose.foundation.layout.PaddingValues drawPadding; - property public final long glowColor; + @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class OverscrollConfiguration { + ctor @Deprecated public OverscrollConfiguration(); + ctor @Deprecated public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding); + method @Deprecated public androidx.compose.foundation.layout.PaddingValues getDrawPadding(); + method @Deprecated public long getGlowColor(); + property @Deprecated public final androidx.compose.foundation.layout.PaddingValues drawPadding; + property @Deprecated public final long glowColor; } public final class OverscrollConfiguration_androidKt { - method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalOverscrollConfiguration(); - property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalOverscrollConfiguration; + method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalOverscrollConfiguration(); + property @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalOverscrollConfiguration; } @androidx.compose.runtime.Stable public interface OverscrollEffect { method public suspend Object? applyToFling(long velocity, kotlin.jvm.functions.Function2,? extends java.lang.Object?> performFling, kotlin.coroutines.Continuation); method public long applyToScroll(long delta, int source, kotlin.jvm.functions.Function1 performScroll); - method public androidx.compose.ui.Modifier getEffectModifier(); + method @Deprecated public default androidx.compose.ui.Modifier getEffectModifier(); + method public default androidx.compose.ui.node.DelegatableNode getNode(); method public boolean isInProgress(); - property public abstract androidx.compose.ui.Modifier effectModifier; + property @Deprecated public default androidx.compose.ui.Modifier effectModifier; property public abstract boolean isInProgress; + property public default androidx.compose.ui.node.DelegatableNode node; + } + + public interface OverscrollFactory { + method public androidx.compose.foundation.OverscrollEffect createOverscrollEffect(); + method public boolean equals(Object? other); + method public int hashCode(); } public final class OverscrollKt { - method public static androidx.compose.ui.Modifier overscroll(androidx.compose.ui.Modifier, androidx.compose.foundation.OverscrollEffect overscrollEffect); + method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalOverscrollFactory(); + method public static androidx.compose.ui.Modifier overscroll(androidx.compose.ui.Modifier, androidx.compose.foundation.OverscrollEffect? overscrollEffect); + method @androidx.compose.runtime.Composable public static androidx.compose.foundation.OverscrollEffect? rememberOverscrollEffect(); + method @androidx.compose.runtime.Stable public static androidx.compose.foundation.OverscrollEffect withoutDrawing(androidx.compose.foundation.OverscrollEffect); + method @androidx.compose.runtime.Stable public static androidx.compose.foundation.OverscrollEffect withoutEventHandling(androidx.compose.foundation.OverscrollEffect); + property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalOverscrollFactory; } public final class PreferKeepClear_androidKt { @@ -259,8 +274,10 @@ package androidx.compose.foundation { } public final class ScrollKt { + method public static androidx.compose.ui.Modifier horizontalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); method public static androidx.compose.ui.Modifier horizontalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); method @androidx.compose.runtime.Composable public static androidx.compose.foundation.ScrollState rememberScrollState(optional int initial); + method public static androidx.compose.ui.Modifier verticalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); method public static androidx.compose.ui.Modifier verticalScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.ScrollState state, optional boolean enabled, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean reverseScrolling); } @@ -433,8 +450,10 @@ package androidx.compose.foundation.gestures { } @androidx.compose.runtime.Stable public final class AnchoredDraggableState { - ctor public AnchoredDraggableState(T initialValue, androidx.compose.foundation.gestures.DraggableAnchors anchors, optional kotlin.jvm.functions.Function1 confirmValueChange); - ctor public AnchoredDraggableState(T initialValue, optional kotlin.jvm.functions.Function1 confirmValueChange); + ctor public AnchoredDraggableState(T initialValue); + ctor public AnchoredDraggableState(T initialValue, androidx.compose.foundation.gestures.DraggableAnchors anchors); + ctor @Deprecated public AnchoredDraggableState(T initialValue, androidx.compose.foundation.gestures.DraggableAnchors anchors, optional kotlin.jvm.functions.Function1 confirmValueChange); + ctor @Deprecated public AnchoredDraggableState(T initialValue, kotlin.jvm.functions.Function1 confirmValueChange); method public suspend Object? anchoredDrag(optional androidx.compose.foundation.MutatePriority dragPriority, kotlin.jvm.functions.Function3,? super kotlin.coroutines.Continuation,? extends java.lang.Object?> block, kotlin.coroutines.Continuation); method public suspend Object? anchoredDrag(T targetValue, optional androidx.compose.foundation.MutatePriority dragPriority, kotlin.jvm.functions.Function4,? super T,? super kotlin.coroutines.Continuation,? extends java.lang.Object?> block, kotlin.coroutines.Continuation); method public float dispatchRawDelta(float delta); @@ -469,8 +488,9 @@ package androidx.compose.foundation.gestures { } public static final class AnchoredDraggableState.Companion { + method public androidx.compose.runtime.saveable.Saver,T> Saver(); method @Deprecated public androidx.compose.runtime.saveable.Saver,T> Saver(androidx.compose.animation.core.AnimationSpec snapAnimationSpec, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, kotlin.jvm.functions.Function1 positionalThreshold, kotlin.jvm.functions.Function0 velocityThreshold, optional kotlin.jvm.functions.Function1 confirmValueChange); - method public androidx.compose.runtime.saveable.Saver,T> Saver(optional kotlin.jvm.functions.Function1 confirmValueChange); + method @Deprecated public androidx.compose.runtime.saveable.Saver,T> Saver(optional kotlin.jvm.functions.Function1 confirmValueChange); } @androidx.compose.runtime.Stable public interface BringIntoViewSpec { @@ -592,7 +612,7 @@ package androidx.compose.foundation.gestures { public final class ScrollableDefaults { method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior(); - method @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect(); + method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect(); method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling); field public static final androidx.compose.foundation.gestures.ScrollableDefaults INSTANCE; } @@ -826,9 +846,11 @@ package androidx.compose.foundation.interaction { package androidx.compose.foundation.lazy { public final class LazyDslKt { - method @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method @Deprecated @androidx.compose.runtime.Composable public static void LazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, kotlin.jvm.functions.Function1 content); - method @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method @Deprecated @androidx.compose.runtime.Composable public static void LazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.LazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, kotlin.jvm.functions.Function1 content); method public static inline void items(androidx.compose.foundation.lazy.LazyListScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function1 contentType, kotlin.jvm.functions.Function2 itemContent); method @Deprecated public static inline void items(androidx.compose.foundation.lazy.LazyListScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, kotlin.jvm.functions.Function2 itemContent); @@ -975,13 +997,14 @@ package androidx.compose.foundation.lazy.grid { } @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class GridItemSpan { - method public int getCurrentLineSpan(); property public final int currentLineSpan; } public final class LazyGridDslKt { - method @androidx.compose.runtime.Composable public static void LazyHorizontalGrid(androidx.compose.foundation.lazy.grid.GridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); - method @androidx.compose.runtime.Composable public static void LazyVerticalGrid(androidx.compose.foundation.lazy.grid.GridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyHorizontalGrid(androidx.compose.foundation.lazy.grid.GridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyHorizontalGrid(androidx.compose.foundation.lazy.grid.GridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyVerticalGrid(androidx.compose.foundation.lazy.grid.GridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyVerticalGrid(androidx.compose.foundation.lazy.grid.GridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.grid.LazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method public static inline void items(androidx.compose.foundation.lazy.grid.LazyGridScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function2? span, optional kotlin.jvm.functions.Function1 contentType, kotlin.jvm.functions.Function2 itemContent); method public static inline void items(androidx.compose.foundation.lazy.grid.LazyGridScope, T[] items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function2? span, optional kotlin.jvm.functions.Function1 contentType, kotlin.jvm.functions.Function2 itemContent); method public static inline void itemsIndexed(androidx.compose.foundation.lazy.grid.LazyGridScope, java.util.List items, optional kotlin.jvm.functions.Function2? key, optional kotlin.jvm.functions.Function3? span, optional kotlin.jvm.functions.Function2 contentType, kotlin.jvm.functions.Function3 itemContent); @@ -1011,6 +1034,8 @@ package androidx.compose.foundation.lazy.grid { } public static final class LazyGridItemInfo.Companion { + property public static final int UnknownColumn; + property public static final int UnknownRow; field public static final int UnknownColumn = -1; // 0xffffffff field public static final int UnknownRow = -1; // 0xffffffff } @@ -1258,8 +1283,10 @@ package androidx.compose.foundation.lazy.layout { package androidx.compose.foundation.lazy.staggeredgrid { public final class LazyStaggeredGridDslKt { - method @androidx.compose.runtime.Composable public static void LazyHorizontalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional float horizontalItemSpacing, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); - method @androidx.compose.runtime.Composable public static void LazyVerticalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional float verticalItemSpacing, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyHorizontalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional float horizontalItemSpacing, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyHorizontalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional float horizontalItemSpacing, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void LazyVerticalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional float verticalItemSpacing, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function1 content); + method @Deprecated @androidx.compose.runtime.Composable public static void LazyVerticalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional float verticalItemSpacing, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1 content); method public static inline void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function1 contentType, optional kotlin.jvm.functions.Function1? span, kotlin.jvm.functions.Function2 itemContent); method public static inline void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, T[] items, optional kotlin.jvm.functions.Function1? key, optional kotlin.jvm.functions.Function1 contentType, optional kotlin.jvm.functions.Function1? span, kotlin.jvm.functions.Function2 itemContent); method public static inline void itemsIndexed(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List items, optional kotlin.jvm.functions.Function2? key, optional kotlin.jvm.functions.Function2 contentType, optional kotlin.jvm.functions.Function2? span, kotlin.jvm.functions.Function3 itemContent); @@ -1410,13 +1437,16 @@ package androidx.compose.foundation.pager { public final class PagerDefaults { method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.foundation.pager.PagerSnapDistance pagerSnapDistance, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec snapAnimationSpec, optional @FloatRange(from=0.0, to=1.0) float snapPositionalThreshold); method @androidx.compose.runtime.Composable public androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection(androidx.compose.foundation.pager.PagerState state, androidx.compose.foundation.gestures.Orientation orientation); + property public static final int BeyondViewportPageCount; field public static final int BeyondViewportPageCount = 0; // 0x0 field public static final androidx.compose.foundation.pager.PagerDefaults INSTANCE; } public final class PagerKt { - method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); - method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); + method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function2 pageContent); + method @Deprecated @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); + method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, optional androidx.compose.foundation.OverscrollEffect? overscrollEffect, kotlin.jvm.functions.Function2 pageContent); + method @Deprecated @androidx.compose.runtime.Composable public static void VerticalPager(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.pager.PageSize pageSize, optional int beyondViewportPageCount, optional float pageSpacing, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1? key, optional androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition, kotlin.jvm.functions.Function2 pageContent); } public sealed interface PagerLayoutInfo { @@ -1605,7 +1635,7 @@ package androidx.compose.foundation.shape { method @androidx.compose.runtime.Stable public static androidx.compose.foundation.shape.CornerSize CornerSize(float size); method @androidx.compose.runtime.Stable public static androidx.compose.foundation.shape.CornerSize CornerSize(@IntRange(from=0L, to=100L) int percent); method public static androidx.compose.foundation.shape.CornerSize getZeroCornerSize(); - property public static final androidx.compose.foundation.shape.CornerSize ZeroCornerSize; + property @androidx.compose.runtime.Stable public static final androidx.compose.foundation.shape.CornerSize ZeroCornerSize; } public final class CutCornerShape extends androidx.compose.foundation.shape.CornerBasedShape { @@ -1651,6 +1681,39 @@ package androidx.compose.foundation.shape { package androidx.compose.foundation.text { + public sealed interface AutoSize { + field public static final androidx.compose.foundation.text.AutoSize.Companion Companion; + } + + public static final class AutoSize.Companion { + method public androidx.compose.foundation.text.AutoSize StepBased(optional long minFontSize, optional long maxFontSize, optional long stepSize); + } + + public final class AutoSizeDefaults { + method public long getMaxFontSize(); + method public long getMinFontSize(); + property public final long MaxFontSize; + property public final long MinFontSize; + field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE; + } + + @kotlin.jvm.JvmInline public final value class AutofillHighlight { + ctor public AutofillHighlight(long autofillHighlightColor); + method public long getAutofillHighlightColor(); + property public final long autofillHighlightColor; + field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion; + } + + public static final class AutofillHighlight.Companion { + method public long getDefault(); + property public final long Default; + } + + public final class AutofillHighlightKt { + method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofillHighlight(); + property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofillHighlight; + } + public final class BasicSecureTextFieldKt { method @Deprecated @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional kotlin.jvm.functions.Function2,kotlin.Unit>? onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.text.input.TextFieldDecorator? decorator, optional int textObfuscationMode, optional char textObfuscationCharacter); method @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional kotlin.jvm.functions.Function2,kotlin.Unit>? onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.text.input.TextFieldDecorator? decorator, optional int textObfuscationMode, optional char textObfuscationCharacter); @@ -1666,11 +1729,13 @@ package androidx.compose.foundation.text { public final class BasicTextKt { method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent); - method @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent, optional androidx.compose.ui.graphics.ColorProducer? color); + method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent, optional androidx.compose.ui.graphics.ColorProducer? color); + method @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional java.util.Map inlineContent, optional androidx.compose.ui.graphics.ColorProducer? color, optional androidx.compose.foundation.text.AutoSize? autoSize); method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(androidx.compose.ui.text.AnnotatedString text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional java.util.Map inlineContent); method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines); method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines); - method @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional androidx.compose.ui.graphics.ColorProducer? color); + method @Deprecated @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional androidx.compose.ui.graphics.ColorProducer? color); + method @androidx.compose.runtime.Composable public static void BasicText(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.text.TextStyle style, optional kotlin.jvm.functions.Function1? onTextLayout, optional int overflow, optional boolean softWrap, optional int maxLines, optional int minLines, optional androidx.compose.ui.graphics.ColorProducer? color, optional androidx.compose.foundation.text.AutoSize? autoSize); } public final class ClickableTextKt { @@ -1716,7 +1781,7 @@ package androidx.compose.foundation.text { public static final class KeyboardActions.Companion { method public androidx.compose.foundation.text.KeyboardActions getDefault(); - property public final androidx.compose.foundation.text.KeyboardActions Default; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.text.KeyboardActions Default; } public final class KeyboardActionsKt { @@ -1750,14 +1815,14 @@ package androidx.compose.foundation.text { property public final int imeAction; property public final int keyboardType; property public final androidx.compose.ui.text.input.PlatformImeOptions? platformImeOptions; - property public boolean shouldShowKeyboardOnFocus; + property @Deprecated public boolean shouldShowKeyboardOnFocus; property public final Boolean? showKeyboardOnFocus; field public static final androidx.compose.foundation.text.KeyboardOptions.Companion Companion; } public static final class KeyboardOptions.Companion { method public androidx.compose.foundation.text.KeyboardOptions getDefault(); - property public final androidx.compose.foundation.text.KeyboardOptions Default; + property @androidx.compose.runtime.Stable public final androidx.compose.foundation.text.KeyboardOptions Default; } } diff --git a/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/OverscrollBenchmark.kt b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/OverscrollBenchmark.kt index 44e83a9e7fc63..a39e403161dae 100644 --- a/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/OverscrollBenchmark.kt +++ b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/OverscrollBenchmark.kt @@ -21,7 +21,6 @@ import android.view.View import androidx.compose.foundation.background import androidx.compose.foundation.benchmark.lazy.MotionEventHelper import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box @@ -30,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.overscroll +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -132,7 +132,7 @@ private class OverscrollTestCase : LayeredComposeTestCase(), ToggleableTestCase get() = true } } - val overscrollEffect = ScrollableDefaults.overscrollEffect() + val overscrollEffect = rememberOverscrollEffect() Box( Modifier.scrollable( wrappedScrollState, diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ExpandedTouchBoundsDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ExpandedTouchBoundsDemo.kt new file mode 100644 index 0000000000000..77024cd4747c0 --- /dev/null +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ExpandedTouchBoundsDemo.kt @@ -0,0 +1,324 @@ +/* + * 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. + */ + +package androidx.compose.foundation.demos + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.TouchBoundsExpansion +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.node.requireDensity +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import kotlin.random.Random + +private val padding = 50.dp + +@Composable +fun ExpandedTouchBoundsDemo() { + LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) { + item { + Text( + "When hit at overlapped expanded touch bounds, the pointer input is shared. \n" + + "When directly hit at a Box that's overlapped with expanded touch bounds, " + + " only the Box get the event." + ) + Column(modifier = Modifier.padding(vertical = padding)) { + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector()) + Spacer(modifier = Modifier.height(padding / 2)) + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) + Spacer(modifier = Modifier.height(padding / 2)) + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) + } + } + + item { + Text( + "When hit at overlapped expanded touch bound, the pointer events can be" + + " shared with cousins." + ) + Column(modifier = Modifier.padding(vertical = padding)) { + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) + Spacer(modifier = Modifier.height(padding / 2)) + Column { + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) { + Text(text = "Cousin", modifier = Modifier.align(Alignment.Center)) + } + } + } + } + + item { + Text( + "When the expanded touch bounds overlapped with sibling's child, the " + + "sibling and its child get the event." + ) + Column(modifier = Modifier.padding(vertical = padding)) { + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) + Spacer(modifier = Modifier.height(padding)) + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) { + Box( + modifier = + Modifier.size(50.dp, 50.dp) + .offset(x = 25.dp, y = (-50).dp) + .touchDetector() + ) + } + } + } + + item { + Text( + "When the parent has pointer input modifier, the child can still receive" + + " event in the expanded touch bounds." + ) + Box(modifier = Modifier.size(300.dp, 250.dp).touchDetector()) { + Box( + modifier = + Modifier.size(100.dp, 50.dp).align(Alignment.Center).touchDetector(padding) + ) + } + } + + item { + Text( + "When the expanded touch bounds overlapped with a sibling's minimum touch " + + "bounds, the node with expanded touch bounds gets the event.\n" + + "Note: the minimum touch bounds is drawn in dotted line." + ) + Column(modifier = Modifier.padding(vertical = padding)) { + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) {} + Spacer(modifier = Modifier.height(padding)) + CompositionLocalProvider( + LocalViewConfiguration provides + object : ViewConfiguration by LocalViewConfiguration.current { + override val minimumTouchTargetSize = DpSize(200.dp, 150.dp) + } + ) { + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector()) + } + } + } + + item { + Text( + "Parent can't intercept out of bounds child events if the child is hit in" + + "expanded touch bounds" + ) + Box(modifier = Modifier.padding(padding)) { + Box( + modifier = + Modifier.size(300.dp, 150.dp) + .touchDetector(interceptOutOfBoundsChildEvents = true) + ) { + Box( + modifier = + Modifier.size(200.dp, 100.dp) + .touchDetector(interceptOutOfBoundsChildEvents = true) + ) { + Box(modifier = Modifier.size(100.dp, 50.dp).touchDetector(padding)) + } + } + } + } + } +} + +/** + * A touch detector whose touch bounds was expanded by [touchBoundsExpansion] [Dp]s in each + * direction. + */ +fun Modifier.touchDetector(touchBoundsExpansion: Dp): Modifier = + this then + TouchDetectorWithExpandedBoundsElement( + interceptOutOfBoundsChildEvents = false, + touchBoundsExpansionDp = touchBoundsExpansion + ) + +fun Modifier.touchDetector(interceptOutOfBoundsChildEvents: Boolean = false): Modifier = + this then TouchDetectorWithExpandedBoundsElement(interceptOutOfBoundsChildEvents, 0.dp) + +internal data class TouchDetectorWithExpandedBoundsElement( + val interceptOutOfBoundsChildEvents: Boolean, + val touchBoundsExpansionDp: Dp +) : ModifierNodeElement() { + override fun create(): TouchDetectorWithExpandedBoundsNode { + return TouchDetectorWithExpandedBoundsNode( + nextColor(), + interceptOutOfBoundsChildEvents, + touchBoundsExpansionDp + ) + } + + override fun update(node: TouchDetectorWithExpandedBoundsNode) { + node.interceptOutOfBoundsChildEvents = interceptOutOfBoundsChildEvents + node.touchBoundsExpansionDp = touchBoundsExpansionDp + } + + override fun InspectorInfo.inspectableProperties() { + name = "TouchDetectorWithExpandedBounds" + properties["interceptOutOfBoundsChildEvents"] = interceptOutOfBoundsChildEvents + properties["touchBoundsExpansionDp"] = touchBoundsExpansionDp + } +} + +internal class TouchDetectorWithExpandedBoundsNode( + val background: Color = Color.Cyan, + var interceptOutOfBoundsChildEvents: Boolean = false, + var touchBoundsExpansionDp: Dp = 0.dp +) : + DelegatingNode(), + PointerInputModifierNode, + DrawModifierNode, + CompositionLocalConsumerModifierNode { + private var color = background + + override val touchBoundsExpansion: TouchBoundsExpansion + get() = + if (touchBoundsExpansionDp.value <= 0) { + TouchBoundsExpansion.None + } else { + val touchBoundsExpansionPx = + with(requireDensity()) { touchBoundsExpansionDp.toPx().toInt() } + TouchBoundsExpansion.Absolute( + touchBoundsExpansionPx, + touchBoundsExpansionPx, + touchBoundsExpansionPx, + touchBoundsExpansionPx + ) + } + + private val pointerInputNode = + delegate( + SuspendingPointerInputModifierNode { + awaitEachGesture { + awaitFirstDown() + color = background.highlight() + invalidateDraw() + while (true) { + val event = awaitPointerEvent() + if (event.changes.fastAny { it.changedToUp() }) { + color = background + invalidateDraw() + break + } + } + } + } + ) + + private val minimumTouchTargetSize: DpSize + get() = currentValueOf(LocalViewConfiguration).minimumTouchTargetSize + + override fun interceptOutOfBoundsChildEvents(): Boolean { + return interceptOutOfBoundsChildEvents + } + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) { + pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) + } + + override fun onCancelPointerInput() { + pointerInputNode.onCancelPointerInput() + } + + override fun ContentDrawScope.draw() { + drawRect(color) + // Draw the expanded touch bounds if touchBoundsExpansion is not None. + if (touchBoundsExpansion != TouchBoundsExpansion.None) { + val touchBoundsExpansionPx = touchBoundsExpansionDp.toPx() + drawRect( + background.copy(alpha = 0.75f), + topLeft = Offset(-touchBoundsExpansionPx, -touchBoundsExpansionPx), + size = + Size( + width = size.width + touchBoundsExpansionPx * 2, + height = size.height + touchBoundsExpansionPx * 2 + ), + style = Stroke(width = 2f) + ) + } + + // Draw the minimal touch target bounds if it's larger than the touch bounds of this + // modifier. + val minTouchTargetWidth = minimumTouchTargetSize.width.toPx() + val minTouchTargetHeight = minimumTouchTargetSize.height.toPx() + if (size.width < minTouchTargetWidth || size.height < minTouchTargetHeight) { + drawRect( + background.copy(alpha = 0.75f), + topLeft = + Offset( + (size.width - minTouchTargetWidth) / 2, + (size.height - minTouchTargetHeight) / 2 + ), + size = Size(width = minTouchTargetWidth, height = minTouchTargetHeight), + style = + Stroke(width = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))) + ) + } + drawContent() + } +} + +private val random = Random(0) + +fun nextColor(): Color { + return Color(random.nextInt() or 0xFF000000.toInt()) +} + +fun Color.highlight(): Color { + return lerp(this, Color.Black, 0.5f) +} diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/FoundationDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/FoundationDemos.kt index 56666f0d18065..ce745c086445a 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/FoundationDemos.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/FoundationDemos.kt @@ -78,6 +78,7 @@ val FoundationDemos = DemoCategory("High-level Gestures", GestureDemos), DemoCategory("Drag and drop", DragAndDropDemos), ComposableDemo("Combined clickable") { CombinedClickableDemo() }, + ComposableDemo("Expanded touch bounds ") { ExpandedTouchBoundsDemo() }, ComposableDemo("Overscroll") { OverscrollDemo() }, ComposableDemo("Can scroll forward / backward") { CanScrollSample() }, ComposableDemo("Vertical scroll") { VerticalScrollExample() }, diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/OverscrollDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/OverscrollDemo.kt index 56d322d564a1b..0214eadaeff11 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/OverscrollDemo.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/OverscrollDemo.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.samples.OverscrollRenderedOnTopOfLazyListDecorations import androidx.compose.foundation.samples.OverscrollSample import androidx.compose.foundation.samples.OverscrollWithDraggable_After import androidx.compose.foundation.samples.OverscrollWithDraggable_Before @@ -42,5 +43,7 @@ fun OverscrollDemo() { OverscrollWithDraggable_Before() Spacer(Modifier.height(50.dp)) OverscrollWithDraggable_After() + Spacer(Modifier.height(50.dp)) + OverscrollRenderedOnTopOfLazyListDecorations() } } diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt index aae1809d34d98..6bf91760be4c2 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.stylusHoverIcon import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -62,6 +63,7 @@ fun PointerIconPartialOverlapDemo() { Modifier.fillMaxSize() .border(BorderStroke(2.dp, SolidColor(Color.Red))) .pointerHoverIcon(PointerIcon.Crosshair) + .stylusHoverIcon(PointerIcon.Crosshair) ) { Text(text = "expected crosshair") Box( @@ -69,6 +71,7 @@ fun PointerIconPartialOverlapDemo() { .fillMaxWidth(0.6f) .border(BorderStroke(2.dp, SolidColor(Color.Black))) .pointerHoverIcon(PointerIcon.Hand, true) + .stylusHoverIcon(PointerIcon.Hand, true) ) { Text(text = "expected hand") } @@ -83,12 +86,14 @@ fun PointerIconFullOverlapDemo() { Modifier.fillMaxSize() .border(BorderStroke(2.dp, SolidColor(Color.Red))) .pointerHoverIcon(PointerIcon.Crosshair) + .stylusHoverIcon(PointerIcon.Crosshair) ) { Text(text = "expected crosshair") Box( Modifier.fillMaxSize() .border(BorderStroke(2.dp, SolidColor(Color.Black))) .pointerHoverIcon(PointerIcon.Hand) + .stylusHoverIcon(PointerIcon.Hand) ) { Text(text = "expected hand") } @@ -108,6 +113,7 @@ fun PointerIconNonOverlappingParentsDemo() { .requiredSize(50.dp) .border(BorderStroke(2.dp, SolidColor(Color.Black))) .pointerHoverIcon(PointerIcon.Hand) + .stylusHoverIcon(PointerIcon.Hand) ) { Text("hand") } @@ -116,6 +122,7 @@ fun PointerIconNonOverlappingParentsDemo() { .requiredSize(50.dp) .border(BorderStroke(2.dp, SolidColor(Color.Blue))) .pointerHoverIcon(PointerIcon.Crosshair) + .stylusHoverIcon(PointerIcon.Crosshair) ) { Text("crosshair") } @@ -135,6 +142,7 @@ fun PointerIconOverlappingSiblingsDemo() { .requiredSize(120.dp, 60.dp) .border(BorderStroke(2.dp, SolidColor(Color.Black))) .pointerHoverIcon(PointerIcon.Hand) + .stylusHoverIcon(PointerIcon.Hand) ) { Text(text = "expected hand") } @@ -143,6 +151,7 @@ fun PointerIconOverlappingSiblingsDemo() { .requiredSize(120.dp, 20.dp) .border(BorderStroke(2.dp, SolidColor(Color.Blue))) .pointerHoverIcon(PointerIcon.Crosshair) + .stylusHoverIcon(PointerIcon.Crosshair) ) { Text(text = "expected crosshair") } @@ -157,6 +166,7 @@ fun PointerIconMultiLayeredNestingDemo() { Modifier.requiredSize(200.dp) .border(BorderStroke(2.dp, SolidColor(Color.Red))) .pointerHoverIcon(PointerIcon.Crosshair) + .stylusHoverIcon(PointerIcon.Crosshair) ) { Text(text = "expected crosshair") Box( @@ -164,6 +174,7 @@ fun PointerIconMultiLayeredNestingDemo() { .requiredSize(150.dp) .border(BorderStroke(2.dp, SolidColor(Color.Black))) .pointerHoverIcon(PointerIcon.Text) + .stylusHoverIcon(PointerIcon.Text) ) { Text(text = "expected text") Box( @@ -171,6 +182,7 @@ fun PointerIconMultiLayeredNestingDemo() { .requiredSize(100.dp) .border(BorderStroke(2.dp, SolidColor(Color.Blue))) .pointerHoverIcon(PointerIcon.Hand) + .stylusHoverIcon(PointerIcon.Hand) ) { Text(text = "expected hand") } @@ -189,6 +201,7 @@ fun PointerIconChildNotFullyOverlappedByParentDemo() { .requiredSize(width = 200.dp, height = 150.dp) .border(BorderStroke(2.dp, SolidColor(Color.Red))) .pointerHoverIcon(PointerIcon.Crosshair, overrideDescendants = false) + .stylusHoverIcon(PointerIcon.Crosshair, overrideDescendants = false) ) { Text(text = "expected crosshair") Box( @@ -196,6 +209,7 @@ fun PointerIconChildNotFullyOverlappedByParentDemo() { .requiredSize(width = 150.dp, height = 125.dp) .border(BorderStroke(2.dp, SolidColor(Color.Black))) .pointerHoverIcon(PointerIcon.Text, overrideDescendants = false) + .stylusHoverIcon(PointerIcon.Text, overrideDescendants = false) ) { Text(text = "expected text") Box( @@ -204,6 +218,7 @@ fun PointerIconChildNotFullyOverlappedByParentDemo() { .offset(x = 100.dp) .border(BorderStroke(2.dp, SolidColor(Color.Blue))) .pointerHoverIcon(PointerIcon.Hand, overrideDescendants = false) + .stylusHoverIcon(PointerIcon.Hand, overrideDescendants = false) ) { Text(text = "expected hand") } diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeText.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeText.kt index 4169d00e9f935..ef16a7eca2989 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeText.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeText.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -979,3 +980,18 @@ fun EllipsizeDemo() { ) } } + +@Composable +fun AutoSizeTextDemo() { + val text = "This is a sample string!" + Column(Modifier.fillMaxWidth()) { + // This text will be sized according to default values of AutoSize.StepBased + BasicText(text, autoSize = AutoSize.StepBased()) + // This text can either have a font size of 10, 20, 30, 40, 50 or 60 sp + BasicText( + text, + autoSize = + AutoSize.StepBased(minFontSize = 10.sp, maxFontSize = 60.sp, stepSize = 10.sp) + ) + } +} diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt index a53882fe6c3a8..fe61539516365 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt @@ -99,6 +99,7 @@ val TextDemos = ComposableDemo("Line Height Behavior") { TextLineHeightDemo() }, ComposableDemo("Layout Reuse") { TextReuseLayoutDemo() }, ComposableDemo("Multi paragraph") { MultiParagraphDemo() }, + ComposableDemo("Auto Size") { AutoSizeTextDemo() } ) ), DemoCategory( diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldDemos.kt index fc672cdac3c48..d037b319d4718 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldDemos.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldDemos.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.delete import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.Checkbox @@ -81,6 +82,9 @@ fun BasicTextFieldDemos() { TagLine(tag = "BasicTextField Edit Controls") BasicTextFieldEditControls() + + TagLine(tag = "BasicTextField Programmatic Edit") + BasicTextFieldProgrammaticEdit() } } @@ -183,3 +187,35 @@ fun BasicTextFieldEditControls() { ) } } + +@Composable +fun BasicTextFieldProgrammaticEdit() { + val state = remember { TextFieldState() } + Column { + Row { + Button(onClick = { state.edit { replace(selection.start, selection.end, "A") } }) { + Text("A") + } + Button(onClick = { state.edit { replace(selection.start, selection.end, "B") } }) { + Text("B") + } + Button( + onClick = { + state.edit { + if (selection.collapsed) { + delete((selection.min - 1).coerceAtLeast(0), selection.min) + } else { + delete(selection.start, selection.end) + } + } + } + ) { + Text("Backspace") + } + } + BasicTextField( + state = state, + modifier = demoTextFieldModifiers, + ) + } +} diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt index 50b0ea8a96981..763c5c207d667 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt @@ -24,7 +24,12 @@ import androidx.compose.foundation.lazy.grid.scrollBy import androidx.compose.runtime.Stable import androidx.compose.testutils.assertIsEqualTo import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo @@ -35,6 +40,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import org.junit.Rule @@ -159,6 +165,51 @@ open class BaseLazyLayoutTestWithOrientation(private val orientation: Orientatio internal fun Modifier.debugBorder(color: Color = Color.Black) = border(1.dp, color) + internal class TestOverscrollEffect : OverscrollEffect { + var applyToScrollCalledCount: Int = 0 + private set + + var applyToFlingCalledCount: Int = 0 + private set + + var scrollOverscrollDelta: Offset = Offset.Zero + private set + + var flingOverscrollVelocity: Velocity = Velocity.Zero + private set + + var drawCalled: Boolean = false + + override fun applyToScroll( + delta: Offset, + source: NestedScrollSource, + performScroll: (Offset) -> Offset + ): Offset { + applyToScrollCalledCount++ + val consumed = performScroll(delta) + scrollOverscrollDelta = delta - consumed + return consumed + } + + override suspend fun applyToFling( + velocity: Velocity, + performFling: suspend (Velocity) -> Velocity + ) { + applyToFlingCalledCount++ + val consumed = performFling(velocity) + flingOverscrollVelocity = velocity - consumed + } + + override val isInProgress: Boolean = false + override val node: DelegatableNode = + object : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + drawContent() + drawCalled = true + } + } + } + companion object { internal const val FrameDuration = 16L } diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/BaseLazyGridTestWithOrientation.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/BaseLazyGridTestWithOrientation.kt index 18b78f9227c8b..6e37486599f64 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/BaseLazyGridTestWithOrientation.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/BaseLazyGridTestWithOrientation.kt @@ -19,12 +19,14 @@ package androidx.compose.foundation.lazy.grid import androidx.compose.animation.core.snap import androidx.compose.foundation.AutoTestFrameClock import androidx.compose.foundation.BaseLazyLayoutTestWithOrientation +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.test.SemanticsNodeInteraction @@ -58,6 +60,7 @@ open class BaseLazyGridTestWithOrientation(orientation: Orientation) : reverseArrangement: Boolean = false, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), crossAxisSpacedBy: Dp = 0.dp, mainAxisSpacedBy: Dp = 0.dp, content: LazyGridScope.() -> Unit @@ -71,6 +74,7 @@ open class BaseLazyGridTestWithOrientation(orientation: Orientation) : reverseArrangement, flingBehavior, userScrollEnabled, + overscrollEffect, crossAxisSpacedBy, mainAxisSpacedBy, content @@ -86,6 +90,7 @@ open class BaseLazyGridTestWithOrientation(orientation: Orientation) : reverseArrangement: Boolean = false, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), crossAxisSpacedBy: Dp = 0.dp, mainAxisSpacedBy: Dp = 0.dp, content: LazyGridScope.() -> Unit @@ -110,6 +115,7 @@ open class BaseLazyGridTestWithOrientation(orientation: Orientation) : reverseLayout = reverseLayout, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, verticalArrangement = verticalArrangement, horizontalArrangement = horizontalArrangement, content = content @@ -134,6 +140,7 @@ open class BaseLazyGridTestWithOrientation(orientation: Orientation) : reverseLayout = reverseLayout, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, horizontalArrangement = horizontalArrangement, verticalArrangement = verticalArrangement, content = content diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridTest.kt index dd7e786bff561..19f412aec4dd9 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridTest.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridTest.kt @@ -19,23 +19,37 @@ package androidx.compose.foundation.lazy.grid import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.AutoTestFrameClock +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.list.TestTouchSlop +import androidx.compose.foundation.lazy.list.assertIsNotPlaced +import androidx.compose.foundation.lazy.list.assertIsPlaced import androidx.compose.foundation.lazy.list.setContentWithTestViewConfiguration import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -49,7 +63,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.SemanticsActions @@ -65,6 +84,8 @@ import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.swipeWithVelocity import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -73,6 +94,7 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round import androidx.compose.ui.unit.size import androidx.compose.ui.zIndex import androidx.test.filters.MediumTest @@ -81,9 +103,12 @@ import com.google.common.collect.Range import com.google.common.truth.IntegerSubject import com.google.common.truth.Truth import com.google.common.truth.Truth.assertThat +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @@ -1361,8 +1386,1057 @@ class LazyGridTest(private val orientation: Orientation) : .assertCrossAxisStartPositionInRootIsEqualTo(0.dp) .assertCrossAxisSizeIsEqualTo(itemSizeDp) } + + @Test + fun customOverscroll() { + val overscroll = TestOverscrollEffect() + + val items = (1..4).map { it.toString() } + rule.setContent { + val state = rememberLazyGridState() + LazyGrid( + cells = 1, + state = state, + modifier = Modifier.size(200.dp).testTag("grid"), + overscrollEffect = overscroll + ) { + items(items) { Spacer(Modifier.size(101.dp)) } + } + } + + // The overscroll modifier should be added / drawn + rule.runOnIdle { assertThat(overscroll.drawCalled).isTrue() } + + // Swipe backwards to trigger overscroll + rule.onNodeWithTag("grid").performTouchInput { if (vertical) swipeDown() else swipeRight() } + + rule.runOnIdle { + // The swipe will result in multiple scroll deltas + assertThat(overscroll.applyToScrollCalledCount).isGreaterThan(1) + assertThat(overscroll.applyToFlingCalledCount).isEqualTo(1) + if (vertical) { + assertThat(overscroll.scrollOverscrollDelta.y).isGreaterThan(0) + assertThat(overscroll.flingOverscrollVelocity.y).isGreaterThan(0) + } else { + assertThat(overscroll.scrollOverscrollDelta.x).isGreaterThan(0) + assertThat(overscroll.flingOverscrollVelocity.x).isGreaterThan(0) + } + } + } + + @Test + fun testLookaheadPositionWithOnlyInBoundChanges() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3), + targetList = listOf(3, 2, 1, 0), + cells = 1, + initialExpectedLookaheadPositions = + if (vertical) { + listOf(IntOffset(0, 0), IntOffset(0, 100), IntOffset(0, 200), IntOffset(0, 300)) + } else { + listOf(IntOffset(0, 0), IntOffset(100, 0), IntOffset(200, 0), IntOffset(300, 0)) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf(IntOffset(0, 300), IntOffset(0, 200), IntOffset(0, 100), IntOffset(0, 0)) + } else { + listOf(IntOffset(300, 0), IntOffset(200, 0), IntOffset(100, 0), IntOffset(0, 0)) + }, + ) + } + + @Test + fun testLookaheadPositionWithCustomStartingIndex() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), + targetList = listOf(9, 8, 7, 6, 5, 4, 3, 2, 1, 0), + cells = 2, + initialExpectedLookaheadPositions = + if (vertical) { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(100, 0), + IntOffset(0, 100), + IntOffset(100, 100), + IntOffset(0, 200), + IntOffset(100, 200), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(UnspecifiedOffset, 300), + IntOffset(UnspecifiedOffset, 300) + ) + } else { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(0, 100), + IntOffset(100, 0), + IntOffset(100, 100), + IntOffset(200, 0), + IntOffset(200, 100), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(300, UnspecifiedOffset), + IntOffset(300, UnspecifiedOffset) + ) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf( + IntOffset(100, 300), + IntOffset(0, 300), + IntOffset(100, 200), + IntOffset(0, 200), + IntOffset(100, 100), + IntOffset(0, 100), + IntOffset(100, 0), + IntOffset(0, 0), + IntOffset(0, -100), + IntOffset(100, -100) + ) + } else { + listOf( + IntOffset(300, 100), + IntOffset(300, 0), + IntOffset(200, 100), + IntOffset(200, 0), + IntOffset(100, 100), + IntOffset(100, 0), + IntOffset(0, 100), + IntOffset(0, 0), + IntOffset(-100, 0), + IntOffset(-100, 100) + ) + }, + startingIndex = 2, + crossAxisSize = 200 + ) + } + + @Test + fun testLookaheadPositionWithTwoInBoundTwoOutBound() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3, 4, 5), + targetList = listOf(5, 4, 2, 1, 3, 0), + initialExpectedLookaheadPositions = + if (vertical) { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(0, 100), + IntOffset(0, 200), + IntOffset(0, 300) + ) + } else { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(100, 0), + IntOffset(200, 0), + IntOffset(300, 0) + ) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf( + IntOffset(0, 300), + IntOffset(0, 100), + IntOffset(0, 0), + IntOffset(0, 200), + IntOffset(0, -100), + IntOffset(0, -200) + ) + } else { + listOf( + IntOffset(300, 0), + IntOffset(100, 0), + IntOffset(0, 0), + IntOffset(200, 0), + IntOffset(-100, 0), + IntOffset(-200, 0) + ) + }, + startingIndex = 2 + ) + } + + @Test + fun testLookaheadPositionWithFourInBoundFourOutBound() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), + targetList = listOf(8, 9, 7, 6, 4, 5, 2, 1, 3, 0), + initialExpectedLookaheadPositions = + if (vertical) { + listOf( + null, + null, + null, + null, + IntOffset(0, 0), + IntOffset(100, 0), + IntOffset(0, 100), + IntOffset(100, 100), + IntOffset(0, 200), + IntOffset(100, 200) + ) + } else { + listOf( + null, + null, + null, + null, + IntOffset(0, 0), + IntOffset(0, 100), + IntOffset(100, 0), + IntOffset(100, 100), + IntOffset(200, 0), + IntOffset(200, 100) + ) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf( + IntOffset(100, 200), + IntOffset(100, 100), + IntOffset(0, 100), + IntOffset(0, 200), + IntOffset(0, 0), + IntOffset(100, 0), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(UnspecifiedOffset, -100), + IntOffset(UnspecifiedOffset, -100), + IntOffset(UnspecifiedOffset, -200), + IntOffset(UnspecifiedOffset, -200) + ) + } else { + listOf( + IntOffset(200, 100), + IntOffset(100, 100), + IntOffset(100, 0), + IntOffset(200, 0), + IntOffset(0, 0), + IntOffset(0, 100), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(-100, UnspecifiedOffset), + IntOffset(-100, UnspecifiedOffset), + IntOffset(-200, UnspecifiedOffset), + IntOffset(-200, UnspecifiedOffset) + ) + }, + startingIndex = 4, + cells = 2, + crossAxisSize = 200 + ) + } + + private fun testLookaheadPositionWithPlacementAnimator( + initialList: List, + targetList: List, + cells: Int = 1, + initialExpectedLookaheadPositions: List, + targetExpectedLookaheadPositions: List, + startingIndex: Int = 0, + crossAxisSize: Int? = null + ) { + val itemSize = 100 + var list by mutableStateOf(initialList) + val lookaheadPosition = mutableMapOf() + val approachPosition = mutableMapOf() + rule.mainClock.autoAdvance = false + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyGridInLookaheadScope( + list = list, + cells = cells, + startingIndex = startingIndex, + lookaheadPosition = lookaheadPosition, + approachPosition = approachPosition, + itemSize = itemSize, + crossAxisSize = crossAxisSize + ) + } + } + rule.runOnIdle { + repeat(list.size) { + assertOffsetEquals(initialExpectedLookaheadPositions[it], lookaheadPosition[it]) + assertOffsetEquals(initialExpectedLookaheadPositions[it], approachPosition[it]) + } + lookaheadPosition.clear() + approachPosition.clear() + list = targetList + } + rule.waitForIdle() + repeat(20) { + rule.mainClock.advanceTimeByFrame() + repeat(list.size) { + assertOffsetEquals(targetExpectedLookaheadPositions[it], lookaheadPosition[it]) + } + } + repeat(list.size) { + if ( + lookaheadPosition[it]?.let { offset -> + (if (vertical) offset.y else offset.x) + itemSize >= 0 + } != false + ) { + assertOffsetEquals(lookaheadPosition[it], approachPosition[it]) + } + } + } + + private fun assertOffsetEquals(expected: IntOffset?, actual: IntOffset?) { + if (expected == null || actual == null) return assertEquals(expected, actual) + if (expected.x == UnspecifiedOffset || actual.x == UnspecifiedOffset) { + // Only compare y offset + assertEquals(expected.y, actual.y) + } else if (expected.y == UnspecifiedOffset || actual.y == UnspecifiedOffset) { + assertEquals(expected.x, actual.x) + } else { + assertEquals(expected, actual) + } + } + + @Composable + private fun LazyGridInLookaheadScope( + list: List, + cells: Int, + startingIndex: Int, + lookaheadPosition: MutableMap, + approachPosition: MutableMap, + itemSize: Int, + crossAxisSize: Int? = null + ) { + LookaheadScope { + LazyGrid( + cells = cells, + if (vertical) { + Modifier.requiredHeight(itemSize.dp * (list.size - startingIndex) / cells) + .then( + if (crossAxisSize != null) Modifier.requiredWidth(crossAxisSize.dp) + else Modifier + ) + } else { + Modifier.requiredWidth(itemSize.dp * (list.size - startingIndex) / cells) + .then( + if (crossAxisSize != null) Modifier.requiredHeight(crossAxisSize.dp) + else Modifier + ) + }, + state = rememberLazyGridState(initialFirstVisibleItemIndex = startingIndex), + ) { + items(list, key = { it }) { item -> + Box( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + item + ) + .requiredSize(itemSize.dp) + ) + } + } + } + } + + private fun Modifier.trackPositions( + lookaheadPosition: MutableMap, + approachPosition: MutableMap, + lookaheadScope: LookaheadScope, + item: Int + ): Modifier = + this.layout { measurable, constraints -> + measurable.measure(constraints).run { + layout(width, height) { + if (isLookingAhead) { + lookaheadPosition[item] = + with(lookaheadScope) { + coordinates!! + .findRootCoordinates() + .localLookaheadPositionOf(coordinates!!) + .round() + } + } else { + approachPosition[item] = coordinates!!.positionInRoot().round() + } + place(0, 0) + } + } + } + + @Test + fun animContentSizeWithPlacementAnimator() { + val itemSize = 100 + val lookaheadPosition = mutableMapOf() + val approachPosition = mutableMapOf() + var large by mutableStateOf(false) + var animateSizeChange by mutableStateOf(false) + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + LazyGrid( + cells = 2, + if (vertical) // Define cross axis size + Modifier.requiredWidth(200.dp) + else Modifier.requiredHeight(200.dp) + ) { + items(8, key = { it }) { + Box( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160, easing = LinearEasing) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + it + ) + .then( + if (animateSizeChange) + Modifier.animateContentSize(tween(160)) + else Modifier + ) + .requiredSize(if (large) itemSize.dp * 2 else itemSize.dp) + ) + } + } + } + } + } + rule.waitForIdle() + repeat(8) { + if (vertical) { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.y) + assertEquals(it / 2 * itemSize, approachPosition[it]?.y) + assertEquals(it % 2 * 100, lookaheadPosition[it]?.x) + assertEquals(it % 2 * 100, approachPosition[it]?.x) + } else { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.x) + assertEquals(it / 2 * itemSize, approachPosition[it]?.x) + assertEquals(it % 2 * 100, lookaheadPosition[it]?.y) + assertEquals(it % 2 * 100, approachPosition[it]?.y) + } + } + + rule.mainClock.autoAdvance = false + large = true + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + repeat(20) { frame -> + val fraction = (frame * 16 / 160f).coerceAtMost(1f) + repeat(8) { + if (vertical) { + assertEquals(it / 2 * itemSize * 2, lookaheadPosition[it]?.y) + assertEquals( + (it / 2 * itemSize * (1 + fraction)).roundToInt(), + approachPosition[it]?.y + ) + } else { + assertEquals(it / 2 * itemSize * 2, lookaheadPosition[it]?.x) + assertEquals( + (it / 2 * itemSize * (1 + fraction)).roundToInt(), + approachPosition[it]?.x + ) + } + } + rule.mainClock.advanceTimeByFrame() + } + + // Enable animateContentSize + animateSizeChange = true + large = false + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + repeat(20) { frame -> + val fraction = (frame * 16 / 160f).coerceAtMost(1f) + repeat(4) { + // Verify that item target offsets are not affected by animateContentSize + if (vertical) { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.y) + assertEquals( + (it / 2 * (2 - fraction) * itemSize).roundToInt(), + approachPosition[it]?.y + ) + } else { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.x) + assertEquals( + (it / 2 * (2 - fraction) * itemSize).roundToInt(), + approachPosition[it]?.x + ) + } + } + rule.mainClock.advanceTimeByFrame() + } + } + + @Test + fun animVisibilityWithPlacementAnimator() { + val lookaheadPosition = mutableMapOf() + val approachPosition = mutableMapOf() + var visible by mutableStateOf(false) + val itemSize = 100 + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + LazyGrid(cells = 1) { + items(4, key = { it }) { + if (vertical) { + Column( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160, easing = LinearEasing) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + it + ) + ) { + Box(Modifier.requiredSize(itemSize.dp)) + AnimatedVisibility(visible = visible) { + Box(Modifier.requiredSize(itemSize.dp)) + } + } + } else { + Row( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160, easing = LinearEasing) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + it + ) + ) { + Box(Modifier.requiredSize(itemSize.dp)) + AnimatedVisibility(visible = visible) { + Box(Modifier.requiredSize(itemSize.dp)) + } + } + } + } + } + } + } + } + rule.waitForIdle() + repeat(4) { + assertEquals(it * itemSize, lookaheadPosition[it]?.mainAxisPosition) + assertEquals(it * itemSize, approachPosition[it]?.mainAxisPosition) + } + + rule.mainClock.autoAdvance = false + visible = true + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + repeat(20) { frame -> + val fraction = (frame * 16 / 160f).coerceAtMost(1f) + repeat(4) { + assertEquals(it * itemSize * 2, lookaheadPosition[it]?.mainAxisPosition) + assertEquals( + (it * itemSize * (1 + fraction)).roundToInt(), + approachPosition[it]?.mainAxisPosition + ) + } + rule.mainClock.advanceTimeByFrame() + } + } + + @Test + fun resizeLazyGridOnlyDuringApproach() { + val itemSize = 100 + val lookaheadPositions = mutableMapOf() + val approachPositions = mutableMapOf() + var approachSize by mutableStateOf(itemSize * 2) + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyGrid( + cells = 2, + Modifier.layout { measurable, _ -> + val constraints = + if (isLookingAhead) { + Constraints.fixed(4 * itemSize, 4 * itemSize) + } else { + Constraints.fixed(approachSize, approachSize) + } + measurable.measure(constraints).run { + layout(width, height) { place(0, 0) } + } + } + ) { + items(8) { + Box( + Modifier.requiredSize(itemSize.dp).layout { measurable, constraints + -> + measurable.measure(constraints).run { + layout(width, height) { + if (isLookingAhead) { + lookaheadPositions[it] = + coordinates!! + .findRootCoordinates() + .localLookaheadPositionOf(coordinates!!) + } else { + approachPositions[it] = + coordinates!!.positionInRoot() + } + } + } + } + ) + } + } + } + } + } + rule.runOnIdle { + repeat(8) { + assertEquals((it / 2) * itemSize, lookaheadPositions[it]?.mainAxisPosition) + } + assertEquals(0, approachPositions[0]?.mainAxisPosition) + assertEquals(0, approachPositions[1]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[2]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[3]?.mainAxisPosition) + assertEquals(null, approachPositions[4]?.mainAxisPosition) + assertEquals(null, approachPositions[5]?.mainAxisPosition) + assertEquals(null, approachPositions[6]?.mainAxisPosition) + assertEquals(null, approachPositions[7]?.mainAxisPosition) + } + approachSize = (2.9f * itemSize).toInt() + rule.runOnIdle { + repeat(8) { assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) } + assertEquals(0, approachPositions[0]?.mainAxisPosition) + assertEquals(0, approachPositions[1]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[2]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[3]?.mainAxisPosition) + assertEquals(itemSize * 2, approachPositions[4]?.mainAxisPosition) + assertEquals(itemSize * 2, approachPositions[5]?.mainAxisPosition) + assertEquals(null, approachPositions[6]?.mainAxisPosition) + assertEquals(null, approachPositions[7]?.mainAxisPosition) + } + approachSize = (3.4f * itemSize).toInt() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } + } + + // Shrinking approach size + approachSize = (2.7f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 6) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + + // Shrinking approach size + approachSize = (1.2f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 4) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + } + + @Test + fun lookaheadSizeSmallerThanPostLookahead() { + val itemSize = 100 + val lookaheadPositions = mutableMapOf() + val approachPositions = mutableMapOf() + val lookaheadSize by mutableStateOf(itemSize * 2) + var approachSize by mutableStateOf(itemSize * 4) + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyGrid( + cells = 2, + Modifier.layout { measurable, _ -> + val constraints = + if (isLookingAhead) { + Constraints.fixed(lookaheadSize, lookaheadSize) + } else { + Constraints.fixed(approachSize, approachSize) + } + measurable.measure(constraints).run { + layout(width, height) { place(0, 0) } + } + } + ) { + items(8) { + Box( + Modifier.requiredSize(itemSize.dp).layout { measurable, constraints + -> + measurable.measure(constraints).run { + layout(width, height) { + if (isLookingAhead) { + lookaheadPositions[it] = + coordinates!! + .findRootCoordinates() + .localLookaheadPositionOf(coordinates!!) + } else { + approachPositions[it] = + coordinates!!.positionInRoot() + } + } + } + } + ) + } + } + } + } + } + // approachSize was initialized to 4 * ItemSize + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } + } + approachSize = (2.9f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 6) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + approachSize = 2 * itemSize + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 4) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + + // Growing approach size + approachSize = (2.7f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 6) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + + // Shrinking approach size + approachSize = (1.2f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 4) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + } + + @Test + fun approachItemsComposed() { + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + LazyGrid(cells = 2, Modifier.requiredSize(300.dp)) { + items(24, key = { it }) { + Box( + Modifier.testTag("$it") + .then( + if (it == 0) { + Modifier.layout { measurable, constraints -> + val p = measurable.measure(constraints) + val size = if (isLookingAhead) 300 else 30 + layout(size, size) { p.place(0, 0) } + } + } else Modifier.size(30.dp) + ) + ) + } + } + } + } + } + rule.waitForIdle() + + // Based on lookahead item 0 & 1 would be the only item needed, but approach calculation + // indicates 10 items will be needed to fill the viewport. + for (i in 0 until 20) { + rule.onNodeWithTag("$i").assertIsPlaced() + } + for (i in 20 until 24) { + rule.onNodeWithTag("$i").assertDoesNotExist() + } + } + + @Test + fun approachItemsComposedBasedOnScrollDelta() { + var lookaheadSize by mutableStateOf(30) + var approachSize by mutableStateOf(lookaheadSize) + lateinit var state: LazyGridState + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + state = LazyGridState() + LazyGrid(cells = 2, Modifier.requiredSize(300.dp), state) { + items(24, key = { it }) { + Box( + Modifier.testTag("$it") + .then( + if (it == 4) { + Modifier.layout { measurable, constraints -> + val p = measurable.measure(constraints) + val size = + if (isLookingAhead) lookaheadSize + else approachSize + layout(size, size) { p.place(0, 0) } + } + } else Modifier.size(30.dp) + ) + ) + } + } + } + } + } + rule.waitForIdle() + + for (i in 0 until 24) { + if (i < 20) { + rule.onNodeWithTag("$i").assertIsPlaced() + } else { + rule.onNodeWithTag("$i").assertDoesNotExist() + } + } + + lookaheadSize = 300 + rule.runOnIdle { runBlocking { state.scrollBy(60f) } } + rule.waitForIdle() + + rule.onNodeWithTag("0").assertIsNotPlaced() + rule.onNodeWithTag("1").assertIsNotPlaced() + rule.onNodeWithTag("2").assertIsNotPlaced() + rule.onNodeWithTag("3").assertIsNotPlaced() + for (i in 4 until 24) { + rule.onNodeWithTag("$i").assertIsPlaced() + } + + approachSize = 300 + rule.waitForIdle() + for (i in 0 until 24) { + if (i == 4 || i == 5) { + rule.onNodeWithTag("$i").assertIsPlaced() + } else { + rule.onNodeWithTag("$i").assertIsNotPlaced() + } + } + } + + @Test + fun testDisposeHappensAfterNoLongerNeededByEitherPass() { + + val disposed = mutableListOf().apply { repeat(20) { this.add(false) } } + var lookaheadHeight by mutableIntStateOf(1000) + var approachHeight by mutableIntStateOf(1000) + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyVerticalGrid( + GridCells.Fixed(2), + Modifier.layout { m, _ -> + val c = + if (isLookingAhead) Constraints.fixed(400, lookaheadHeight) + else Constraints.fixed(400, approachHeight) + m.measure(c).run { layout(width, lookaheadHeight) { place(0, 0) } } + } + ) { + items(20) { + Box(Modifier.height(100.dp).fillMaxWidth()) + DisposableEffect(Unit) { onDispose { disposed[it] = true } } + } + } + } + } + } + rule.runOnIdle { repeat(20) { assertEquals(false, disposed[it]) } } + approachHeight = 400 + rule.waitForIdle() + lookaheadHeight = 400 + + rule.runOnIdle { + repeat(20) { + if (it < 8) { + assertEquals(false, disposed[it]) + } else { + assertEquals(true, disposed[it]) + } + } + } + lookaheadHeight = 300 + + rule.runOnIdle { repeat(8) { assertEquals(false, disposed[it]) } } + } + + @Test + fun testNoOverScroll() { + val state = LazyGridState() + var firstItemOffset: Offset? = null + var lastItemOffset: Offset? = null + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + CompositionLocalProvider(LocalOverscrollFactory provides null) { + Box(Modifier.testTag("grid")) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = state, + modifier = Modifier.requiredHeight(500.dp).fillMaxWidth() + ) { + items(30) { + BasicText( + "$it", + Modifier.then( + if (it == 0 || it == 29) + Modifier.onGloballyPositioned { c -> + // Checking on each placement there's no + // overscroll + if (it == 0) { + firstItemOffset = c.positionInRoot() + assertTrue(firstItemOffset!!.y <= 0f) + } else { + lastItemOffset = c.positionInRoot() + assertTrue(lastItemOffset!!.y >= 400f) + } + } + else Modifier + ) + .height(100.dp) + .animateItem() + ) + } + } + } + } + } + } + } + + // Scroll beyond bounds in both directions + repeat(20) { + rule.runOnIdle { runBlocking { state.scrollBy(200f) } } + if (it == 19) { + assertEquals(20, state.firstVisibleItemIndex) + rule.runOnIdle { runBlocking { state.scrollToItem(14) } } + rule.onNodeWithTag("grid").performTouchInput { swipeUp(durationMillis = 50) } + } + // Checking on each iteration there is no overscroll + assertTrue(firstItemOffset == null || firstItemOffset!!.y <= 0) + assertTrue(lastItemOffset == null || lastItemOffset!!.y >= 400) + } + + repeat(20) { + rule.runOnIdle { runBlocking { state.scrollBy(-200f) } } + if (it == 19) { + assertEquals(0, state.firstVisibleItemIndex) + rule.runOnIdle { runBlocking { state.scrollToItem(7) } } + rule.onNodeWithTag("grid").performTouchInput { swipeDown(durationMillis = 50) } + } + // Checking on each iteration there is no overscroll + assertTrue(firstItemOffset == null || firstItemOffset!!.y <= 0) + assertTrue(lastItemOffset == null || lastItemOffset!!.y >= 400) + } + } + + @Test + fun testSmallScrollWithLookaheadScope() { + val itemSize = 10 + val itemSizeDp = with(rule.density) { itemSize.toDp() } + val containerSizeDp = with(rule.density) { 15.toDp() } + val scrollDelta = 2f + val scrollDeltaDp = with(rule.density) { scrollDelta.toDp() } + val state = LazyGridState() + lateinit var scope: CoroutineScope + rule.setContent { + scope = rememberCoroutineScope() + LookaheadScope { + LazyGrid(cells = 1, Modifier.mainAxisSize(containerSizeDp), state = state) { + repeat(20) { item { Box(Modifier.size(itemSizeDp).testTag("$it")) } } + } + } + } + + rule.runOnIdle { runBlocking { scope.launch { state.scrollBy(scrollDelta) } } } + + rule.onNodeWithTag("0").assertMainAxisStartPositionInRootIsEqualTo(-scrollDeltaDp) + rule + .onNodeWithTag("1") + .assertMainAxisStartPositionInRootIsEqualTo(itemSizeDp - scrollDeltaDp) + } + + private val Offset.mainAxisPosition: Int + get() = (if (vertical) this.y else this.x).roundToInt() + + private val IntOffset.mainAxisPosition: Int + get() = if (vertical) this.y else this.x } +const val UnspecifiedOffset = 0xFFF_FFFF + internal fun IntegerSubject.isEqualTo(expected: Int, tolerance: Int) { isIn(Range.closed(expected - tolerance, expected + tolerance)) } diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt index 367485e673518..03a2d779b534b 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt @@ -21,6 +21,7 @@ package androidx.compose.foundation.lazy.list import androidx.compose.animation.core.snap import androidx.compose.foundation.AutoTestFrameClock import androidx.compose.foundation.BaseLazyLayoutTestWithOrientation +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.composeViewSwipeDown import androidx.compose.foundation.composeViewSwipeLeft import androidx.compose.foundation.composeViewSwipeRight @@ -40,6 +41,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -120,6 +122,7 @@ open class BaseLazyListTestWithOrientation(private val orientation: Orientation) reverseArrangement: Boolean = false, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), spacedBy: Dp = 0.dp, isCrossAxis: Boolean = false, content: LazyListScope.() -> Unit @@ -138,6 +141,7 @@ open class BaseLazyListTestWithOrientation(private val orientation: Orientation) reverseLayout = reverseLayout, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, verticalArrangement = verticalArrangement, content = content ) @@ -155,6 +159,7 @@ open class BaseLazyListTestWithOrientation(private val orientation: Orientation) reverseLayout = reverseLayout, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, horizontalArrangement = horizontalArrangement, content = content ) @@ -238,6 +243,7 @@ private fun LazyColumn( isVertical = true, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), beyondBoundsItemCount = beyondBoundsItemCount, content = content ) @@ -267,6 +273,7 @@ private fun LazyRow( flingBehavior = flingBehavior, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), beyondBoundsItemCount = beyondBoundsItemCount, content = content ) diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListHeadersTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListHeadersTest.kt index 8ee217ecbec4f..ad4524292b390 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListHeadersTest.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListHeadersTest.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -417,6 +418,7 @@ private fun LazyColumn( isVertical = true, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), beyondBoundsItemCount = beyondBoundsItemCount, content = content ) @@ -446,6 +448,7 @@ private fun LazyRow( flingBehavior = flingBehavior, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), beyondBoundsItemCount = beyondBoundsItemCount, content = content ) diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt index d2cea9b6c9327..f26696308f4b4 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt @@ -26,6 +26,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.AutoTestFrameClock import androidx.compose.foundation.VelocityTrackerCalculationThreshold import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box @@ -79,6 +80,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -102,12 +104,14 @@ import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.swipeWithVelocity import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex @@ -2018,6 +2022,79 @@ class LazyListTest(orientation: Orientation) : BaseLazyListTestWithOrientation(o .assertMainAxisStartPositionInRootIsEqualTo(itemSizeDp * 2 - scrollDeltaDp) } + @Test + fun testLookaheadItemPlacementAnimatorTarget() { + var mutableSize by mutableStateOf(80) + var lastItemOffset by mutableStateOf(Offset.Zero) + val initialSize = IntSize(200, 200) + val largerCrossAxisSize = if (vertical) IntSize(300, 200) else IntSize(200, 300) + var containerSize by mutableStateOf(initialSize) + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + LazyColumnOrRow( + modifier = + Modifier.requiredSize(containerSize.width.dp, containerSize.height.dp), + beyondBoundsItemCount = 1 + ) { + item { // item 0 + Box(Modifier.requiredSize(40.dp)) + } + item { // item 1. Will change size from 80.dp to 160.dp + Box(Modifier.requiredSize(mutableSize.dp)) + } + item { // item 2 + Box(Modifier.requiredSize(40.dp)) + } + item { // item 3 + Box( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160, easing = LinearEasing) + ) + .onGloballyPositioned { lastItemOffset = it.positionInRoot() } + .requiredSize(80.dp) + ) + } + item { // item 4 + Box(Modifier.requiredSize(1.dp)) + } + } + } + } + } + + rule.waitForIdle() + rule.mainClock.autoAdvance = false + + containerSize = largerCrossAxisSize + mutableSize = 160 + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + + containerSize = initialSize + rule.waitForIdle() + + // Expect last item to move from 160 to 240 within 10 frames + while (lastItemOffset.mainAxisPosition == 160) { + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + } + + repeat(9) { + val expected = (it + 1) * (240 - 160) / 10 + 160 + if (expected <= 200) { // within the viewport + assertEquals((it + 1) * 8 + 160, lastItemOffset.mainAxisPosition) + } else { + // Once the item moves out of the viewport, we don't enforce the exact offset + assertTrue(lastItemOffset.mainAxisPosition >= 200) + } + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + } + } + @Test fun testLookaheadPositionWithTwoInBoundTwoOutBound() { testLookaheadPositionWithPlacementAnimator( @@ -2821,6 +2898,172 @@ class LazyListTest(orientation: Orientation) : BaseLazyListTestWithOrientation(o } } + @Test // b/371168883 + fun whenInnerListIsReused_makeSureFlingUsesCorrectStateForCancellation() { + val state = LazyListState() + lateinit var scope: CoroutineScope + rule.setContent { + scope = rememberCoroutineScope() + LazyColumnOrRow(state = state, modifier = Modifier.size(50.dp)) { + items(20) { main -> + LazyColumnOrRow(modifier = Modifier.testTag("main=$main"), isCrossAxis = true) { + items(100) { item -> + Box(Modifier.size(10.dp).testTag("main=$main item=$item")) { + BasicText("main=$main item=$item") + } + } + } + } + } + } + + rule.runOnIdle { + scope.launch { + state.animateScrollToItem(3) // make sure item 0 is out of view + } + } + + rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(3) } + + // trying to fling the last 3 items + (5..7).forEach { + rule.onNodeWithTag("main=$it").performTouchInput { + if (vertical) { + swipeLeft() + } else { + swipeUp() + } + } + + rule.onNodeWithTag("main=$it item=0").assertIsNotDisplayed() + // we should go back + rule.onNodeWithTag("main=$it").performTouchInput { + if (vertical) { + swipeRight() + } else { + swipeDown() + } + } + // make sure we came back to the start so the fling happened correctly + rule.onNodeWithTag("main=$it item=0").assertIsDisplayed() + } + } + + @Test + fun nestedLists_flingOnInnerListIsCancelled_doesNotStopOuterListFling() { + val state = LazyListState() + var onPreFlingReceived = false + var onListRemoved = false + rule.setContent { + LazyColumnOrRow( + state = state, + modifier = + Modifier.size(100.dp) + .testTag(LazyListTag) + .nestedScroll( + connection = + object : NestedScrollConnection { + override suspend fun onPreFling(available: Velocity): Velocity { + onPreFlingReceived = true + return super.onPreFling(available) + } + } + ) + ) { + items(100) { main -> + if (main == 4) { + DisposableEffect(Unit) { onDispose { onListRemoved = true } } + } + LazyColumnOrRow( + isCrossAxis = true, + modifier = Modifier.testTag(main.toString()) + ) { + items(100) { item -> + Box( + Modifier.size(20.dp).testTag("$main $item").border(1.dp, Color.Red) + ) { + BasicText("$main $item") + } + } + } + } + } + } + rule.mainClock.autoAdvance = false + + // swipe right/up on inner list + rule.onNodeWithTag("4").performTouchInput { + if (vertical) { + swipeLeft() + } else { + swipeUp() + } + } + + // advance time until inner fling is about to begin and let fling play a bit + rule.mainClock.advanceTimeUntil { onPreFlingReceived } + rule.mainClock.advanceTimeBy(100L) + + // swipe outer list + rule.onNodeWithTag(LazyListTag).performTouchInput { + if (vertical) { + swipeWithVelocity(center, topCenter, 5000f) + } else { + swipeWithVelocity(center, centerLeft, 5000f) + } + } + + rule.mainClock.advanceTimeUntil { onListRemoved } // move time more so inner list disappears + rule.onNodeWithTag("4").assertDoesNotExist() // check if inner list was removed + + rule.mainClock.autoAdvance = true + val firstVisibleItemOnOuterList = state.firstVisibleItemIndex + // outer list continued flinging + rule.runOnIdle { + assertThat(state.firstVisibleItemIndex) + .isNotIn(firstVisibleItemOnOuterList - 1..firstVisibleItemOnOuterList + 1) + } + } + + @Test + fun customOverscroll() { + val overscroll = TestOverscrollEffect() + val items = (1..4).map { it.toString() } + + rule.setContentWithTestViewConfiguration { + Box(Modifier.mainAxisSize(200.dp)) { + LazyColumnOrRow(Modifier.testTag(LazyListTag), overscrollEffect = overscroll) { + items(items) { + Spacer( + Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis()).testTag(it) + ) + } + } + } + } + + // The overscroll modifier should be added / drawn + rule.runOnIdle { assertThat(overscroll.drawCalled).isTrue() } + + // Swipe backwards to trigger overscroll + rule.onNodeWithTag(LazyListTag).performTouchInput { + if (vertical) swipeDown() else swipeRight() + } + + rule.runOnIdle { + // The swipe will result in multiple scroll deltas + assertThat(overscroll.applyToScrollCalledCount).isGreaterThan(1) + assertThat(overscroll.applyToFlingCalledCount).isEqualTo(1) + if (vertical) { + assertThat(overscroll.scrollOverscrollDelta.y).isGreaterThan(0) + assertThat(overscroll.flingOverscrollVelocity.y).isGreaterThan(0) + } else { + assertThat(overscroll.scrollOverscrollDelta.x).isGreaterThan(0) + assertThat(overscroll.flingOverscrollVelocity.x).isGreaterThan(0) + } + } + } + // ********************* END OF TESTS ********************* // Helper functions, etc. live below here diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListsIndexedTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListsIndexedTest.kt index 73eaa3eb07ee9..201eabc48d5ea 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListsIndexedTest.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListsIndexedTest.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -197,6 +198,7 @@ private fun LazyColumn( isVertical = true, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), beyondBoundsItemCount = beyondBoundsItemCount, content = content ) @@ -226,6 +228,7 @@ private fun LazyRow( flingBehavior = flingBehavior, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), beyondBoundsItemCount = beyondBoundsItemCount, content = content ) diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/BaseLazyStaggeredGridWithOrientation.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/BaseLazyStaggeredGridWithOrientation.kt index bd4e2c465c064..5d836bccb94ea 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/BaseLazyStaggeredGridWithOrientation.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/BaseLazyStaggeredGridWithOrientation.kt @@ -19,10 +19,12 @@ package androidx.compose.foundation.lazy.staggeredgrid import androidx.compose.animation.core.snap import androidx.compose.foundation.AutoTestFrameClock import androidx.compose.foundation.BaseLazyLayoutTestWithOrientation +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp @@ -54,6 +56,7 @@ open class BaseLazyStaggeredGridWithOrientation(private val orientation: Orienta reverseLayout: Boolean = false, mainAxisSpacing: Dp = 0.dp, crossAxisArrangement: Arrangement.HorizontalOrVertical = Arrangement.spacedBy(0.dp), + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyStaggeredGridScope.() -> Unit, ) { LazyStaggeredGrid( @@ -64,6 +67,7 @@ open class BaseLazyStaggeredGridWithOrientation(private val orientation: Orienta mainAxisSpacing, crossAxisArrangement, reverseLayout, + overscrollEffect, content ) } @@ -89,6 +93,7 @@ open class BaseLazyStaggeredGridWithOrientation(private val orientation: Orienta mainAxisSpacing: Dp = 0.dp, crossAxisArrangement: Arrangement.HorizontalOrVertical = Arrangement.spacedBy(0.dp), reverseLayout: Boolean = false, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyStaggeredGridScope.() -> Unit, ) { if (orientation == Orientation.Vertical) { @@ -100,6 +105,7 @@ open class BaseLazyStaggeredGridWithOrientation(private val orientation: Orienta horizontalArrangement = crossAxisArrangement, state = state, reverseLayout = reverseLayout, + overscrollEffect = overscrollEffect, content = content ) } else { @@ -111,6 +117,7 @@ open class BaseLazyStaggeredGridWithOrientation(private val orientation: Orienta horizontalItemSpacing = mainAxisSpacing, state = state, reverseLayout = reverseLayout, + overscrollEffect = overscrollEffect, content = content ) } diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridInLookaheadTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridInLookaheadTest.kt new file mode 100644 index 0000000000000..d5714def30e91 --- /dev/null +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridInLookaheadTest.kt @@ -0,0 +1,1118 @@ +/* + * 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. + */ + +package androidx.compose.foundation.lazy.staggeredgrid + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.LocalOverscrollFactory +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.UnspecifiedOffset +import androidx.compose.foundation.lazy.list.assertIsNotPlaced +import androidx.compose.foundation.lazy.list.assertIsPlaced +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.test.filters.MediumTest +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@MediumTest +@RunWith(Parameterized::class) +class LazyStaggeredGridInLookaheadTest(private val orientation: Orientation) : + BaseLazyStaggeredGridWithOrientation(orientation) { + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "orientation: {0}") + fun initParameters(): Array = arrayOf(Orientation.Vertical, Orientation.Horizontal) + } + + @Test + fun testLookaheadPositionWithOnlyInBoundChanges() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3), + targetList = listOf(3, 2, 1, 0), + lanes = 1, + initialExpectedLookaheadPositions = + if (vertical) { + listOf(IntOffset(0, 0), IntOffset(0, 100), IntOffset(0, 200), IntOffset(0, 300)) + } else { + listOf(IntOffset(0, 0), IntOffset(100, 0), IntOffset(200, 0), IntOffset(300, 0)) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf(IntOffset(0, 300), IntOffset(0, 200), IntOffset(0, 100), IntOffset(0, 0)) + } else { + listOf(IntOffset(300, 0), IntOffset(200, 0), IntOffset(100, 0), IntOffset(0, 0)) + }, + ) + } + + @Test + fun testLookaheadPositionWithCustomStartingIndex() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), + targetList = listOf(9, 8, 7, 6, 5, 4, 3, 2, 1, 0), + lanes = 2, + initialExpectedLookaheadPositions = + if (vertical) { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(100, 0), + IntOffset(0, 100), + IntOffset(100, 100), + IntOffset(0, 200), + IntOffset(100, 200), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(UnspecifiedOffset, 300), + IntOffset(UnspecifiedOffset, 300) + ) + } else { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(0, 100), + IntOffset(100, 0), + IntOffset(100, 100), + IntOffset(200, 0), + IntOffset(200, 100), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(300, UnspecifiedOffset), + IntOffset(300, UnspecifiedOffset) + ) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf( + IntOffset(100, 300), + IntOffset(0, 300), + IntOffset(100, 200), + IntOffset(0, 200), + IntOffset(100, 100), + IntOffset(0, 100), + IntOffset(100, 0), + IntOffset(0, 0), + IntOffset(0, -100), + IntOffset(100, -100) + ) + } else { + listOf( + IntOffset(300, 100), + IntOffset(300, 0), + IntOffset(200, 100), + IntOffset(200, 0), + IntOffset(100, 100), + IntOffset(100, 0), + IntOffset(0, 100), + IntOffset(0, 0), + IntOffset(-100, 0), + IntOffset(-100, 100) + ) + }, + startingIndex = 2, + crossAxisSize = 200 + ) + } + + @Test + fun testLookaheadPositionWithTwoInBoundTwoOutBound() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3, 4, 5), + targetList = listOf(5, 4, 2, 1, 3, 0), + initialExpectedLookaheadPositions = + if (vertical) { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(0, 100), + IntOffset(0, 200), + IntOffset(0, 300) + ) + } else { + listOf( + null, + null, + IntOffset(0, 0), + IntOffset(100, 0), + IntOffset(200, 0), + IntOffset(300, 0) + ) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf( + IntOffset(0, 300), + IntOffset(0, 100), + IntOffset(0, 0), + IntOffset(0, 200), + IntOffset(0, -100), + IntOffset(0, -200) + ) + } else { + listOf( + IntOffset(300, 0), + IntOffset(100, 0), + IntOffset(0, 0), + IntOffset(200, 0), + IntOffset(-100, 0), + IntOffset(-200, 0) + ) + }, + startingIndex = 2 + ) + } + + @Test + fun testLookaheadPositionWithFourInBoundFourOutBound() { + testLookaheadPositionWithPlacementAnimator( + initialList = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), + targetList = listOf(8, 9, 7, 6, 4, 5, 2, 1, 3, 0), + initialExpectedLookaheadPositions = + if (vertical) { + listOf( + null, + null, + null, + null, + IntOffset(0, 0), + IntOffset(100, 0), + IntOffset(0, 100), + IntOffset(100, 100), + IntOffset(0, 200), + IntOffset(100, 200) + ) + } else { + listOf( + null, + null, + null, + null, + IntOffset(0, 0), + IntOffset(0, 100), + IntOffset(100, 0), + IntOffset(100, 100), + IntOffset(200, 0), + IntOffset(200, 100) + ) + }, + targetExpectedLookaheadPositions = + if (vertical) { + listOf( + IntOffset(100, 200), + IntOffset(100, 100), + IntOffset(0, 100), + IntOffset(0, 200), + IntOffset(0, 0), + IntOffset(100, 0), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(UnspecifiedOffset, -100), + IntOffset(UnspecifiedOffset, -100), + IntOffset(UnspecifiedOffset, -200), + IntOffset(UnspecifiedOffset, -200) + ) + } else { + listOf( + IntOffset(200, 100), + IntOffset(100, 100), + IntOffset(100, 0), + IntOffset(200, 0), + IntOffset(0, 0), + IntOffset(0, 100), + // For items outside the view port *before* the visible items, we only have + // a contract for their mainAxis position. The crossAxis position for those + // items is subject to change. + IntOffset(-100, UnspecifiedOffset), + IntOffset(-100, UnspecifiedOffset), + IntOffset(-200, UnspecifiedOffset), + IntOffset(-200, UnspecifiedOffset) + ) + }, + startingIndex = 4, + lanes = 2, + crossAxisSize = 200 + ) + } + + private fun testLookaheadPositionWithPlacementAnimator( + initialList: List, + targetList: List, + lanes: Int = 1, + initialExpectedLookaheadPositions: List, + targetExpectedLookaheadPositions: List, + startingIndex: Int = 0, + crossAxisSize: Int? = null + ) { + val itemSize = 100 + var list by mutableStateOf(initialList) + val lookaheadPosition = mutableMapOf() + val approachPosition = mutableMapOf() + rule.mainClock.autoAdvance = false + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyStaggeredGridInLookaheadScope( + list = list, + lanes = lanes, + startingIndex = startingIndex, + lookaheadPosition = lookaheadPosition, + approachPosition = approachPosition, + itemSize = itemSize, + crossAxisSize = crossAxisSize + ) + } + } + rule.runOnIdle { + repeat(list.size) { + assertOffsetEquals(initialExpectedLookaheadPositions[it], lookaheadPosition[it]) + assertOffsetEquals(initialExpectedLookaheadPositions[it], approachPosition[it]) + } + lookaheadPosition.clear() + approachPosition.clear() + list = targetList + } + rule.waitForIdle() + repeat(20) { + rule.mainClock.advanceTimeByFrame() + repeat(list.size) { + assertOffsetEquals(targetExpectedLookaheadPositions[it], lookaheadPosition[it]) + } + } + repeat(list.size) { + if ( + lookaheadPosition[it]?.let { offset -> + (if (vertical) offset.y else offset.x) + itemSize >= 0 + } != false + ) { + assertOffsetEquals(lookaheadPosition[it], approachPosition[it]) + } + } + } + + private fun assertOffsetEquals(expected: IntOffset?, actual: IntOffset?) { + if (expected == null || actual == null) return assertEquals(expected, actual) + if (expected.x == UnspecifiedOffset || actual.x == UnspecifiedOffset) { + // Only compare y offset + assertEquals(expected.y, actual.y) + } else if (expected.y == UnspecifiedOffset || actual.y == UnspecifiedOffset) { + assertEquals(expected.x, actual.x) + } else { + assertEquals(expected, actual) + } + } + + @Composable + private fun LazyStaggeredGridInLookaheadScope( + list: List, + lanes: Int, + startingIndex: Int, + lookaheadPosition: MutableMap, + approachPosition: MutableMap, + itemSize: Int, + crossAxisSize: Int? = null + ) { + LookaheadScope { + LazyStaggeredGrid( + lanes = lanes, + if (vertical) { + Modifier.requiredHeight(itemSize.dp * (list.size - startingIndex) / lanes) + .then( + if (crossAxisSize != null) Modifier.requiredWidth(crossAxisSize.dp) + else Modifier + ) + } else { + Modifier.requiredWidth(itemSize.dp * (list.size - startingIndex) / lanes) + .then( + if (crossAxisSize != null) Modifier.requiredHeight(crossAxisSize.dp) + else Modifier + ) + }, + state = + rememberLazyStaggeredGridState(initialFirstVisibleItemIndex = startingIndex), + ) { + items(list, key = { it }) { item -> + Box( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + item + ) + .requiredSize(itemSize.dp) + ) + } + } + } + } + + private fun Modifier.trackPositions( + lookaheadPosition: MutableMap, + approachPosition: MutableMap, + lookaheadScope: LookaheadScope, + item: Int + ): Modifier = + this.layout { measurable, constraints -> + measurable.measure(constraints).run { + layout(width, height) { + if (isLookingAhead) { + lookaheadPosition[item] = + with(lookaheadScope) { + coordinates!! + .findRootCoordinates() + .localLookaheadPositionOf(coordinates!!) + .round() + } + } else { + approachPosition[item] = coordinates!!.positionInRoot().round() + } + place(0, 0) + } + } + } + + @Test + fun animContentSizeWithPlacementAnimator() { + val itemSize = 100 + val lookaheadPosition = mutableMapOf() + val approachPosition = mutableMapOf() + var large by mutableStateOf(false) + var animateSizeChange by mutableStateOf(false) + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + LazyStaggeredGrid( + lanes = 2, + if (vertical) // Define cross axis size + Modifier.requiredWidth(200.dp) + else Modifier.requiredHeight(200.dp) + ) { + items(8, key = { it }) { + Box( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160, easing = LinearEasing) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + it + ) + .then( + if (animateSizeChange) + Modifier.animateContentSize(tween(160)) + else Modifier + ) + .requiredSize(if (large) itemSize.dp * 2 else itemSize.dp) + ) + } + } + } + } + } + rule.waitForIdle() + repeat(8) { + if (vertical) { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.y) + assertEquals(it / 2 * itemSize, approachPosition[it]?.y) + assertEquals(it % 2 * 100, lookaheadPosition[it]?.x) + assertEquals(it % 2 * 100, approachPosition[it]?.x) + } else { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.x) + assertEquals(it / 2 * itemSize, approachPosition[it]?.x) + assertEquals(it % 2 * 100, lookaheadPosition[it]?.y) + assertEquals(it % 2 * 100, approachPosition[it]?.y) + } + } + + rule.mainClock.autoAdvance = false + large = true + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + repeat(20) { frame -> + val fraction = (frame * 16 / 160f).coerceAtMost(1f) + repeat(8) { + if (vertical) { + assertEquals(it / 2 * itemSize * 2, lookaheadPosition[it]?.y) + assertEquals( + (it / 2 * itemSize * (1 + fraction)).roundToInt(), + approachPosition[it]?.y + ) + } else { + assertEquals(it / 2 * itemSize * 2, lookaheadPosition[it]?.x) + assertEquals( + (it / 2 * itemSize * (1 + fraction)).roundToInt(), + approachPosition[it]?.x + ) + } + } + rule.mainClock.advanceTimeByFrame() + } + + // Enable animateContentSize + animateSizeChange = true + large = false + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + repeat(20) { frame -> + val fraction = (frame * 16 / 160f).coerceAtMost(1f) + repeat(4) { + // Verify that item target offsets are not affected by animateContentSize + if (vertical) { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.y) + assertEquals( + (it / 2 * (2 - fraction) * itemSize).roundToInt(), + approachPosition[it]?.y + ) + } else { + assertEquals(it / 2 * itemSize, lookaheadPosition[it]?.x) + assertEquals( + (it / 2 * (2 - fraction) * itemSize).roundToInt(), + approachPosition[it]?.x + ) + } + } + rule.mainClock.advanceTimeByFrame() + } + } + + @Test + fun animVisibilityWithPlacementAnimator() { + val lookaheadPosition = mutableMapOf() + val approachPosition = mutableMapOf() + var visible by mutableStateOf(false) + val itemSize = 100 + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + LazyStaggeredGrid(lanes = 1) { + items(4, key = { it }) { + if (vertical) { + Column( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160, easing = LinearEasing) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + it + ) + ) { + Box(Modifier.requiredSize(itemSize.dp)) + AnimatedVisibility(visible = visible) { + Box(Modifier.requiredSize(itemSize.dp)) + } + } + } else { + Row( + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + placementSpec = tween(160, easing = LinearEasing) + ) + .trackPositions( + lookaheadPosition, + approachPosition, + this@LookaheadScope, + it + ) + ) { + Box(Modifier.requiredSize(itemSize.dp)) + AnimatedVisibility(visible = visible) { + Box(Modifier.requiredSize(itemSize.dp)) + } + } + } + } + } + } + } + } + rule.waitForIdle() + repeat(4) { + assertEquals(it * itemSize, lookaheadPosition[it]?.mainAxisPosition) + assertEquals(it * itemSize, approachPosition[it]?.mainAxisPosition) + } + + rule.mainClock.autoAdvance = false + visible = true + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + repeat(20) { frame -> + val fraction = (frame * 16 / 160f).coerceAtMost(1f) + repeat(4) { + assertEquals(it * itemSize * 2, lookaheadPosition[it]?.mainAxisPosition) + assertEquals( + (it * itemSize * (1 + fraction)).roundToInt(), + approachPosition[it]?.mainAxisPosition + ) + } + rule.mainClock.advanceTimeByFrame() + } + } + + @Test + fun resizeLazyStaggeredGridOnlyDuringApproach() { + val itemSize = 100 + val lookaheadPositions = mutableMapOf() + val approachPositions = mutableMapOf() + var approachSize by mutableStateOf(itemSize * 2) + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyStaggeredGrid( + lanes = 2, + Modifier.layout { measurable, _ -> + val constraints = + if (isLookingAhead) { + Constraints.fixed(4 * itemSize, 4 * itemSize) + } else { + Constraints.fixed(approachSize, approachSize) + } + measurable.measure(constraints).run { + layout(width, height) { place(0, 0) } + } + } + ) { + items(8) { + Box( + Modifier.requiredSize(itemSize.dp).layout { measurable, constraints + -> + measurable.measure(constraints).run { + layout(width, height) { + if (isLookingAhead) { + lookaheadPositions[it] = + coordinates!! + .findRootCoordinates() + .localLookaheadPositionOf(coordinates!!) + } else { + approachPositions[it] = + coordinates!!.positionInRoot() + } + } + } + } + ) + } + } + } + } + } + rule.runOnIdle { + repeat(8) { + assertEquals((it / 2) * itemSize, lookaheadPositions[it]?.mainAxisPosition) + } + assertEquals(0, approachPositions[0]?.mainAxisPosition) + assertEquals(0, approachPositions[1]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[2]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[3]?.mainAxisPosition) + assertEquals(null, approachPositions[4]?.mainAxisPosition) + assertEquals(null, approachPositions[5]?.mainAxisPosition) + assertEquals(null, approachPositions[6]?.mainAxisPosition) + assertEquals(null, approachPositions[7]?.mainAxisPosition) + } + approachSize = (2.9f * itemSize).toInt() + rule.runOnIdle { + repeat(8) { assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) } + assertEquals(0, approachPositions[0]?.mainAxisPosition) + assertEquals(0, approachPositions[1]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[2]?.mainAxisPosition) + assertEquals(itemSize, approachPositions[3]?.mainAxisPosition) + assertEquals(itemSize * 2, approachPositions[4]?.mainAxisPosition) + assertEquals(itemSize * 2, approachPositions[5]?.mainAxisPosition) + assertEquals(null, approachPositions[6]?.mainAxisPosition) + assertEquals(null, approachPositions[7]?.mainAxisPosition) + } + approachSize = (3.4f * itemSize).toInt() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } + } + + // Shrinking approach size + approachSize = (2.7f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 6) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + + // Shrinking approach size + approachSize = (1.2f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + if (it < 4) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + } + + @Test + fun lookaheadSizeSmallerThanPostLookahead() { + val itemSize = 100 + val lookaheadPositions = mutableMapOf() + val approachPositions = mutableMapOf() + val lookaheadSize by mutableStateOf(itemSize * 2) + var approachSize by mutableStateOf(itemSize * 4) + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyStaggeredGrid( + lanes = 2, + Modifier.layout { measurable, _ -> + val constraints = + if (isLookingAhead) { + Constraints.fixed(lookaheadSize, lookaheadSize) + } else { + Constraints.fixed(approachSize, approachSize) + } + measurable.measure(constraints).run { + layout(width, height) { place(0, 0) } + } + } + ) { + items(8) { + Box( + Modifier.requiredSize(itemSize.dp).layout { measurable, constraints + -> + measurable.measure(constraints).run { + layout(width, height) { + if (isLookingAhead) { + lookaheadPositions[it] = + coordinates!! + .findRootCoordinates() + .localLookaheadPositionOf(coordinates!!) + } else { + approachPositions[it] = + coordinates!!.positionInRoot() + } + } + } + } + ) + } + } + } + } + } + // approachSize was initialized to 4 * ItemSize + rule.runOnIdle { + repeat(8) { + if (it < 4) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + } else { + assertTrue(lookaheadPositions[it]?.mainAxisPosition!! >= it / 2 * itemSize) + } + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } + } + approachSize = (2.9f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + if (it < 4) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + } else { + assertTrue(lookaheadPositions[it]?.mainAxisPosition!! >= it / 2 * itemSize) + } + if (it < 6) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + approachSize = 2 * itemSize + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + if (it < 4) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertTrue(lookaheadPositions[it]?.mainAxisPosition!! >= it / 2 * itemSize) + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + + // Growing approach size + approachSize = (2.7f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + if (it < 4) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + } else { + assertTrue(lookaheadPositions[it]?.mainAxisPosition!! >= it / 2 * itemSize) + } + if (it < 6) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + + // Shrinking approach size + approachSize = (1.2f * itemSize).toInt() + approachPositions.clear() + rule.runOnIdle { + repeat(8) { + if (it < 4) { + assertEquals(it / 2 * itemSize, lookaheadPositions[it]?.mainAxisPosition) + } else { + assertTrue(lookaheadPositions[it]?.mainAxisPosition!! >= it / 2 * itemSize) + } + if (it < 4) { + assertEquals(it / 2 * itemSize, approachPositions[it]?.mainAxisPosition) + } else { + assertEquals(null, approachPositions[it]?.mainAxisPosition) + } + } + } + } + + @Test + fun approachItemsComposed() { + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + LazyStaggeredGrid(lanes = 2, Modifier.requiredSize(300.dp)) { + items(24, key = { it }) { + Box( + Modifier.testTag("$it") + .then( + if (it == 0) { + Modifier.layout { measurable, constraints -> + val p = measurable.measure(constraints) + val size = if (isLookingAhead) 300 else 30 + layout(size, size) { p.place(0, 0) } + } + } else Modifier.size(30.dp) + ) + ) + } + } + } + } + } + rule.waitForIdle() + + // Based on lookahead item 0 & 1 would be the only item needed, but approach calculation + // indicates 10 items will be needed to fill the viewport. + for (i in 0 until 20) { + rule.onNodeWithTag("$i").assertIsPlaced() + } + for (i in 20 until 24) { + rule.onNodeWithTag("$i").assertDoesNotExist() + } + } + + @Test + fun approachItemsComposedBasedOnScrollDelta() { + var lookaheadSize by mutableStateOf(30) + var approachSize by mutableStateOf(lookaheadSize) + lateinit var state: LazyStaggeredGridState + rule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LookaheadScope { + state = LazyStaggeredGridState() + LazyStaggeredGrid(lanes = 2, Modifier.requiredSize(300.dp), state) { + items(24, key = { it }) { + Box( + Modifier.testTag("$it") + .then( + if (it == 4) { + Modifier.layout { measurable, constraints -> + val p = measurable.measure(constraints) + val size = + if (isLookingAhead) lookaheadSize + else approachSize + layout(size, size) { p.place(0, 0) } + } + } else Modifier.size(30.dp) + ) + ) + } + } + } + } + } + rule.waitForIdle() + + for (i in 0 until 24) { + if (i < 20) { + rule.onNodeWithTag("$i").assertIsPlaced() + } else { + rule.onNodeWithTag("$i").assertDoesNotExist() + } + } + + lookaheadSize = 300 + rule.runOnIdle { runBlocking { state.scrollBy(60f) } } + rule.waitForIdle() + + rule.onNodeWithTag("0").assertIsNotPlaced() + rule.onNodeWithTag("1").assertIsNotPlaced() + rule.onNodeWithTag("2").assertIsNotPlaced() + rule.onNodeWithTag("3").assertIsNotPlaced() + for (i in 4 until 24) { + rule.onNodeWithTag("$i").assertIsPlaced() + } + + approachSize = 300 + rule.waitForIdle() + for (i in 0 until 24) { + if (i in 4..14) { + rule.onNodeWithTag("$i").assertIsPlaced() + } else { + rule.onNodeWithTag("$i").assertIsNotPlaced() + } + } + } + + @Test + fun testDisposeHappensAfterNoLongerNeededByEitherPass() { + + val disposed = mutableListOf().apply { repeat(20) { this.add(false) } } + var lookaheadHeight by mutableIntStateOf(1000) + var approachHeight by mutableIntStateOf(1000) + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + LazyVerticalStaggeredGrid( + StaggeredGridCells.Fixed(2), + Modifier.layout { m, _ -> + val c = + if (isLookingAhead) Constraints.fixed(400, lookaheadHeight) + else Constraints.fixed(400, approachHeight) + m.measure(c).run { layout(width, lookaheadHeight) { place(0, 0) } } + } + ) { + items(20) { + Box(Modifier.height(100.dp).fillMaxWidth()) + DisposableEffect(Unit) { onDispose { disposed[it] = true } } + } + } + } + } + } + rule.runOnIdle { repeat(20) { assertEquals(false, disposed[it]) } } + approachHeight = 400 + rule.waitForIdle() + lookaheadHeight = 400 + + rule.runOnIdle { + repeat(20) { + if (it < 8) { + assertEquals(false, disposed[it]) + } else { + assertEquals(true, disposed[it]) + } + } + } + lookaheadHeight = 300 + + rule.runOnIdle { repeat(8) { assertEquals(false, disposed[it]) } } + } + + @Test + fun testNoOverScrollWhenSpecified() { + val state = LazyStaggeredGridState() + var firstItemOffset: Offset? = null + var lastItemOffset: Offset? = null + rule.setContent { + LookaheadScope { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + CompositionLocalProvider(LocalOverscrollFactory provides null) { + Box(Modifier.testTag("grid")) { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + state = state, + modifier = Modifier.requiredHeight(500.dp).fillMaxWidth() + ) { + items(30) { + BasicText( + "$it", + Modifier.then( + if (it == 0 || it == 29) + Modifier.onGloballyPositioned { c -> + // Checking on each placement there's no + // overscroll + if (it == 0) { + firstItemOffset = c.positionInRoot() + assertTrue(firstItemOffset!!.y <= 0f) + } else { + lastItemOffset = c.positionInRoot() + assertTrue(lastItemOffset!!.y >= 400f) + } + } + else Modifier + ) + .height(100.dp) + .animateItem() + ) + } + } + } + } + } + } + } + + // Scroll beyond bounds in both directions + repeat(20) { + rule.runOnIdle { runBlocking { state.scrollBy(200f) } } + if (it == 19) { + assertEquals(20, state.firstVisibleItemIndex) + rule.runOnIdle { runBlocking { state.scrollToItem(14) } } + rule.onNodeWithTag("grid").performTouchInput { swipeUp(durationMillis = 50) } + } + // Checking on each iteration there is no overscroll + assertTrue(firstItemOffset == null || firstItemOffset!!.y <= 0) + assertTrue(lastItemOffset == null || lastItemOffset!!.y >= 400) + } + + repeat(20) { + rule.runOnIdle { runBlocking { state.scrollBy(-200f) } } + if (it == 19) { + assertEquals(0, state.firstVisibleItemIndex) + rule.runOnIdle { runBlocking { state.scrollToItem(7) } } + rule.onNodeWithTag("grid").performTouchInput { swipeDown(durationMillis = 50) } + } + // Checking on each iteration there is no overscroll + assertTrue(firstItemOffset == null || firstItemOffset!!.y <= 0) + assertTrue(lastItemOffset == null || lastItemOffset!!.y >= 400) + } + } + + @Test + fun testSmallScrollWithLookaheadScope() { + val itemSize = 10 + val itemSizeDp = with(rule.density) { itemSize.toDp() } + val containerSizeDp = with(rule.density) { 15.toDp() } + val scrollDelta = 2f + val scrollDeltaDp = with(rule.density) { scrollDelta.toDp() } + val state = LazyStaggeredGridState() + lateinit var scope: CoroutineScope + rule.setContent { + scope = rememberCoroutineScope() + LookaheadScope { + LazyStaggeredGrid( + lanes = 1, + Modifier.mainAxisSize(containerSizeDp), + state = state + ) { + repeat(20) { item { Box(Modifier.size(itemSizeDp).testTag("$it")) } } + } + } + } + + rule.runOnIdle { runBlocking { scope.launch { state.scrollBy(scrollDelta) } } } + + rule.onNodeWithTag("0").assertMainAxisStartPositionInRootIsEqualTo(-scrollDeltaDp) + rule + .onNodeWithTag("1") + .assertMainAxisStartPositionInRootIsEqualTo(itemSizeDp - scrollDeltaDp) + } + + private val Offset.mainAxisPosition: Int + get() = (if (vertical) this.y else this.x).roundToInt() + + private val IntOffset.mainAxisPosition: Int + get() = if (vertical) this.y else this.x +} diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt index 855e78a83545b..27e46dbfae038 100644 --- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt +++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.list.assertIsNotPlaced import androidx.compose.foundation.lazy.list.assertIsPlaced import androidx.compose.foundation.lazy.list.setContentWithTestViewConfiguration import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,14 +41,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertPositionInRootIsEqualTo +import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.test.swipeRight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize @@ -65,19 +71,22 @@ import org.junit.runners.Parameterized @MediumTest @RunWith(Parameterized::class) -class LazyStaggeredGridTest(private val orientation: Orientation) : - BaseLazyStaggeredGridWithOrientation(orientation) { +class LazyStaggeredGridTest( + private val orientation: Orientation, + private val useLookahead: Boolean +) : BaseLazyStaggeredGridWithOrientation(orientation) { private val LazyStaggeredGridTag = "LazyStaggeredGridTag" internal lateinit var state: LazyStaggeredGridState companion object { @JvmStatic - @Parameterized.Parameters(name = "{0}") + @Parameterized.Parameters(name = "orientation: {0}, useLookahead: {1}") fun initParameters(): Array = arrayOf( - Orientation.Vertical, - Orientation.Horizontal, + arrayOf(Orientation.Vertical, true), + arrayOf(Orientation.Vertical, false), + arrayOf(Orientation.Horizontal, false) ) } @@ -110,9 +119,31 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : } } + private fun ComposeContentTestRule.setContentWithConfigurableLookahead( + content: @Composable () -> Unit + ) { + setContent { ConfigurableLookaheadScope(useLookahead, content) } + } + + @Composable + private fun ConfigurableLookaheadScope(useLookahead: Boolean, content: @Composable () -> Unit) { + if (useLookahead) { + LookaheadScope { content() } + } else { + content() + } + } + + private fun ComposeContentTestRule.setContentWithTestViewConfiguration( + useLookahead: Boolean, + content: @Composable () -> Unit + ) { + setContentWithTestViewConfiguration { ConfigurableLookaheadScope(useLookahead, content) } + } + @Test fun showsZeroItems() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( @@ -132,7 +163,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun showsOneItem() { val itemTestTag = "itemTestTag" - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( @@ -151,7 +182,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun distributesSingleLine() { - rule.setContent { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( lanes = 3, modifier = Modifier.crossAxisSize(itemSizeDp * 3), @@ -181,7 +212,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun distributesTwoLines() { - rule.setContent { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( lanes = 3, modifier = Modifier.crossAxisSize(itemSizeDp * 3), @@ -241,7 +272,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun moreItemsDisplayedOnScroll() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 3, @@ -287,7 +318,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemSizeInLayoutInfo() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 3, @@ -330,7 +361,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemCanEmitZeroNodes() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 3, @@ -351,7 +382,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsAreHiddenOnScroll() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 3, @@ -380,7 +411,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsArePresentedWhenScrollingBack() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 3, @@ -412,7 +443,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsAreCorrectedWhenSizeIncreased() { var expanded by mutableStateOf(false) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 2, @@ -474,7 +505,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsAreCorrectedWhenSizeDecreased() { var expanded by mutableStateOf(true) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 2, @@ -536,7 +567,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsAreCorrectedWhenItemCountIsIncreasedFromZero() { var itemCount by mutableStateOf(0) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 2, @@ -558,7 +589,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsAreCorrectedWithWrongColumns() { - rule.setContent { + rule.setContentWithConfigurableLookahead { // intentionally wrong values, normally items should be [0, 1][2, 3][4, 5] state = rememberLazyStaggeredGridState( @@ -604,7 +635,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsAreCorrectedWithAlignedOffsets() { var expanded by mutableStateOf(false) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState( initialFirstVisibleItemIndex = 0, @@ -648,7 +679,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun itemsAreCorrectedWhenItemIncreased() { var expanded by mutableStateOf(false) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState( initialFirstVisibleItemIndex = 0, @@ -693,7 +724,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun addItems() { val state = LazyStaggeredGridState() var itemsCount by mutableStateOf(1) - rule.setContent { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( lanes = 2, state = state, @@ -736,7 +767,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun removeItems() { var itemsCount by mutableStateOf(20) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 2, @@ -778,7 +809,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun resizingItems_maintainsScrollingRange() { val state = LazyStaggeredGridState() var itemSizes by mutableStateOf(List(10) { itemSizeDp * (it % 4 + 1) }) - rule.setContent { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( lanes = 2, state = state, @@ -818,7 +849,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun removingItems_maintainsCorrectOffsets() { var itemCount by mutableStateOf(20) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState( initialFirstVisibleItemIndex = 10, @@ -858,7 +889,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun staggeredGrid_supportsLargeIndices() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState( initialFirstVisibleItemIndex = Int.MAX_VALUE / 2, @@ -908,13 +939,15 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : var state: LazyStaggeredGridState? restorationTester.setContent { - state = rememberLazyStaggeredGridState() - LazyStaggeredGrid( - lanes = 3, - state = state!!, - modifier = Modifier.mainAxisSize(itemSizeDp * 10).testTag(LazyStaggeredGridTag) - ) { - items(1000) { Spacer(Modifier.mainAxisSize(itemSizeDp).testTag("$it")) } + ConfigurableLookaheadScope(useLookahead) { + state = rememberLazyStaggeredGridState() + LazyStaggeredGrid( + lanes = 3, + state = state!!, + modifier = Modifier.mainAxisSize(itemSizeDp * 10).testTag(LazyStaggeredGridTag) + ) { + items(1000) { Spacer(Modifier.mainAxisSize(itemSizeDp).testTag("$it")) } + } } } @@ -936,15 +969,17 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : val recomposeCounter = mutableStateOf(0) restorationTester.setContent { - state = rememberLazyStaggeredGridState() - LazyStaggeredGrid( - lanes = 3, - state = state, - modifier = Modifier.mainAxisSize(itemSizeDp * 10).testTag(LazyStaggeredGridTag) - ) { - recomposeCounter.value // read state to force recomposition + ConfigurableLookaheadScope(useLookahead) { + state = rememberLazyStaggeredGridState() + LazyStaggeredGrid( + lanes = 3, + state = state, + modifier = Modifier.mainAxisSize(itemSizeDp * 10).testTag(LazyStaggeredGridTag) + ) { + recomposeCounter.value // read state to force recomposition - items(itemsCount) { Spacer(Modifier.mainAxisSize(itemSizeDp).testTag("$it")) } + items(itemsCount) { Spacer(Modifier.mainAxisSize(itemSizeDp).testTag("$it")) } + } } } @@ -970,7 +1005,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun screenRotate_oneItem_withAdaptiveCells_fillsContentCorrectly() { var rotated by mutableStateOf(false) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() val crossAxis = if (!rotated) itemSizeDp * 6 else itemSizeDp * 9 @@ -1010,7 +1045,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun screenRotate_twoItems_withAdaptiveCells_fillsContentCorrectly() { var rotated by mutableStateOf(false) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() val crossAxis = if (!rotated) itemSizeDp * 6 else itemSizeDp * 9 @@ -1053,7 +1088,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun scrollingALot_layoutIsNotRecomposed() { var recomposed = 0 - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 3, @@ -1079,7 +1114,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun onlyOneInitialMeasurePass() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 3, @@ -1100,7 +1135,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : state.prefetchingEnabled = false val itemSizePx = 5f val itemSize = with(rule.density) { itemSizePx.toDp() } - rule.setContentWithTestViewConfiguration { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( 1, Modifier.testTag(LazyStaggeredGridTag).mainAxisSize(itemSize), @@ -1121,7 +1156,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun fullSpan_fillsAllCrossAxisSpace() { val state = LazyStaggeredGridState() state.prefetchingEnabled = false - rule.setContentWithTestViewConfiguration { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( 3, Modifier.testTag(LazyStaggeredGridTag) @@ -1146,7 +1181,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun fullSpan_leavesEmptyGapsWithOtherItems() { val state = LazyStaggeredGridState() state.prefetchingEnabled = false - rule.setContentWithTestViewConfiguration { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( 3, Modifier.testTag(LazyStaggeredGridTag) @@ -1184,7 +1219,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun fullSpan_leavesGapsBetweenItems() { val state = LazyStaggeredGridState() state.prefetchingEnabled = false - rule.setContentWithTestViewConfiguration { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( 3, Modifier.testTag(LazyStaggeredGridTag) @@ -1230,7 +1265,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun fullSpan_scrollsCorrectly() { val state = LazyStaggeredGridState() state.prefetchingEnabled = false - rule.setContentWithTestViewConfiguration { + rule.setContentWithTestViewConfiguration(useLookahead) { LazyStaggeredGrid( 3, Modifier.testTag(LazyStaggeredGridTag) @@ -1285,7 +1320,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun fullSpan_scrollsCorrectly_pastFullSpan() { val state = LazyStaggeredGridState() state.prefetchingEnabled = false - rule.setContentWithTestViewConfiguration { + rule.setContentWithTestViewConfiguration(useLookahead) { LazyStaggeredGrid( 3, Modifier.testTag(LazyStaggeredGridTag) @@ -1343,7 +1378,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun fullSpan_scrollsCorrectly_pastFullSpan_andBack() { val state = LazyStaggeredGridState() state.prefetchingEnabled = false - rule.setContentWithTestViewConfiguration { + rule.setContentWithTestViewConfiguration(useLookahead) { LazyStaggeredGrid( 3, Modifier.testTag(LazyStaggeredGridTag) @@ -1404,7 +1439,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun fullSpan_scrollsCorrectly_multipleFullSpans() { val state = LazyStaggeredGridState() state.prefetchingEnabled = false - rule.setContentWithTestViewConfiguration { + rule.setContentWithTestViewConfiguration(useLookahead = useLookahead) { LazyStaggeredGrid( 3, Modifier.testTag(LazyStaggeredGridTag) @@ -1447,7 +1482,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun initialIndex_largerThanItemCount_ordersItemsCorrectly_withFullSpan() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState(20) Box(Modifier.mainAxisSize(itemSizeDp * 4)) { LazyStaggeredGrid( @@ -1527,7 +1562,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun initialIndex_largerThanItemCount_ordersItemsCorrectly() { - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState(20) Box(Modifier.mainAxisSize(itemSizeDp * 4)) { LazyStaggeredGrid( @@ -1596,7 +1631,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun changeItemsAndScrollImmediately() { val keys = mutableStateListOf().also { list -> repeat(10) { list.add(it) } } - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid(lanes = 2, Modifier.mainAxisSize(itemSizeDp), state) { items(keys, key = { it }) { Box(Modifier.size(itemSizeDp * 2)) } @@ -1624,7 +1659,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun fixedSizeCell_forcesFixedSize() { val state = LazyStaggeredGridState() - rule.setContent { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( cells = StaggeredGridCells.FixedSize(itemSizeDp * 2), modifier = Modifier.axisSize(crossAxis = itemSizeDp * 5, mainAxis = itemSizeDp * 5), @@ -1647,7 +1682,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun manyPlaceablesInItem_itemSizeIsMaxOfPlaceables() { val state = LazyStaggeredGridState() - rule.setContent { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( lanes = 2, modifier = Modifier.axisSize(crossAxis = itemSizeDp * 2, mainAxis = itemSizeDp * 5), @@ -1672,7 +1707,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun scrollDuringMeasure() { - rule.setContent { + rule.setContentWithConfigurableLookahead { BoxWithConstraints { val state = rememberLazyStaggeredGridState() LazyStaggeredGrid( @@ -1694,7 +1729,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun scrollInLaunchedEffect() { - rule.setContent { + rule.setContentWithConfigurableLookahead { val state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 1, @@ -1714,7 +1749,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun scrollToPreviouslyFullSpanItem() { var firstItemVisible by mutableStateOf(false) - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 2, @@ -1756,7 +1791,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun itemsRemovedAfterLargeThenSmallScrollForward() { lateinit var state: LazyStaggeredGridState val composedItems = mutableSetOf() - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState() LazyStaggeredGrid( lanes = 2, @@ -1791,7 +1826,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun itemsRemovedAfterLargeThenSmallScrollBackward() { lateinit var state: LazyStaggeredGridState val composedItems = mutableSetOf() - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState(initialFirstVisibleItemIndex = 6) LazyStaggeredGrid( lanes = 2, @@ -1826,7 +1861,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun zeroSizeItemIsPlacedWhenItIsAtTheTop() { lateinit var state: LazyStaggeredGridState - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState(initialFirstVisibleItemIndex = 0) LazyStaggeredGrid( lanes = 2, @@ -1871,7 +1906,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : fun itemsAreDistributedCorrectlyOnOverscrollPassWithSameOffset() { val gridHeight = itemSizeDp * 11 // two big items + two small items state = LazyStaggeredGridState() - rule.setContent { + rule.setContentWithConfigurableLookahead { LazyStaggeredGrid( modifier = Modifier.mainAxisSize(gridHeight).crossAxisSize(itemSizeDp * 2), state = state, @@ -1922,7 +1957,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : @Test fun fixedCells_withSpacing_notEnoughSpace() { state = LazyStaggeredGridState() - rule.setContent { + rule.setContentWithConfigurableLookahead { Box(Modifier.size(itemSizeDp)) { LazyStaggeredGrid( modifier = Modifier.mainAxisSize(itemSizeDp * 5), @@ -1948,7 +1983,7 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : // ├───┴───┤ // │ 2 │ // └───────┘ - rule.setContent { + rule.setContentWithConfigurableLookahead { state = rememberLazyStaggeredGridState().apply { prefetchingEnabled = false } LazyStaggeredGrid( lanes = 2, @@ -2025,4 +2060,43 @@ class LazyStaggeredGridTest(private val orientation: Orientation) : rule.onNodeWithTag("2").assertMainAxisStartPositionInRootIsEqualTo(0.dp) assertThat(state.layoutInfo.visibleItemsInfo.map { it.index }).containsExactly(2) } + + @Test + fun customOverscroll() { + val overscroll = TestOverscrollEffect() + + rule.setContentWithConfigurableLookahead { + val state = rememberLazyStaggeredGridState() + LazyStaggeredGrid( + lanes = 2, + state = state, + modifier = + Modifier.mainAxisSize(itemSizeDp * 1.5f) + .crossAxisSize(itemSizeDp * 2) + .testTag("grid"), + overscrollEffect = overscroll + ) { + items(100) { Spacer(Modifier.mainAxisSize(itemSizeDp)) } + } + } + + // The overscroll modifier should be added / drawn + rule.runOnIdle { assertThat(overscroll.drawCalled).isTrue() } + + // Swipe backwards to trigger overscroll + rule.onNodeWithTag("grid").performTouchInput { if (vertical) swipeDown() else swipeRight() } + + rule.runOnIdle { + // The swipe will result in multiple scroll deltas + assertThat(overscroll.applyToScrollCalledCount).isGreaterThan(1) + assertThat(overscroll.applyToFlingCalledCount).isEqualTo(1) + if (vertical) { + assertThat(overscroll.scrollOverscrollDelta.y).isGreaterThan(0) + assertThat(overscroll.flingOverscrollVelocity.y).isGreaterThan(0) + } else { + assertThat(overscroll.scrollOverscrollDelta.x).isGreaterThan(0) + assertThat(overscroll.flingOverscrollVelocity.x).isGreaterThan(0) + } + } + } } diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/AnchoredDraggableSample.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/AnchoredDraggableSample.kt index b84ee4c7520e8..752e939b2c5da 100644 --- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/AnchoredDraggableSample.kt +++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/AnchoredDraggableSample.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.samples +import androidx.annotation.Sampled import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.animate import androidx.compose.foundation.background @@ -24,7 +25,6 @@ import androidx.compose.foundation.gestures.AnchoredDraggableDefaults import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.anchoredDraggable import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.forEach @@ -37,16 +37,20 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.overscroll +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.samples.AnchoredDraggableSampleValue.Center import androidx.compose.foundation.samples.AnchoredDraggableSampleValue.End import androidx.compose.foundation.samples.AnchoredDraggableSampleValue.HalfEnd import androidx.compose.foundation.samples.AnchoredDraggableSampleValue.HalfStart import androidx.compose.foundation.samples.AnchoredDraggableSampleValue.Start +import androidx.compose.material.Button +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -185,7 +189,7 @@ fun AnchoredDraggableWithOverscrollSample() { } val draggableSize = 80.dp val draggableSizePx = with(LocalDensity.current) { draggableSize.toPx() } - val overscrollEffect = ScrollableDefaults.overscrollEffect() + val overscrollEffect = rememberOverscrollEffect() Box( Modifier.fillMaxWidth().onSizeChanged { layoutSize -> @@ -301,6 +305,74 @@ fun DraggableAnchorsSample() { } } +@Sampled +@Composable +fun AnchoredDraggableDynamicAnchorsSample() { + val open = "Open" + val closed = "Closed" + + @Composable + fun DrawerLayout( + state: AnchoredDraggableState, + activePositions: List = listOf(open, closed), + modifier: Modifier = Modifier, + drawerContent: @Composable () -> Unit, + content: @Composable () -> Unit + ) { + Box(modifier) { + Box(Modifier.anchoredDraggable(state, Orientation.Horizontal)) { content() } + Box( + Modifier.onSizeChanged { measuredSize -> + state.updateAnchors( + DraggableAnchors { + if (closed in activePositions) { + closed at -measuredSize.width.toFloat() + } + if (open in activePositions) { + open at 0f + } + } + ) + } + .offset { IntOffset(x = state.requireOffset().roundToInt(), y = 0) } + ) { + drawerContent() + } + } + } + + val state = + rememberSaveable(saver = AnchoredDraggableState.Saver()) { + AnchoredDraggableState(initialValue = closed) + } + val activePositions = remember { mutableStateListOf(open, closed) } + DrawerLayout( + state, + activePositions, + drawerContent = { + Button( + onClick = { + if (closed in activePositions) { + activePositions.remove(closed) + } else { + activePositions.add(closed) + } + } + ) { + val text = + if (closed in activePositions) { + "Click to disallow closing drawer" + } else { + "Click to allow closing" + } + Text(text) + } + }, + ) { + Text("Swipe to expand Drawer") + } +} + /** * A [Modifier] that visualizes the anchors attached to an [AnchoredDraggableState] as lines along * the cross axis of the layout (start to end for [Orientation.Vertical], top to end for diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/OverscrollSample.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/OverscrollSample.kt index 6e7f87502f6a1..3bbae3cab667f 100644 --- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/OverscrollSample.kt +++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/OverscrollSample.kt @@ -22,15 +22,18 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.overscroll +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.foundation.withoutDrawing import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,10 +43,19 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp @@ -116,9 +128,20 @@ fun OverscrollSample() { override val isInProgress: Boolean get() = overscrollOffset.value != 0f - // as we're building an offset modifiers, let's offset of our value we calculated - override val effectModifier: Modifier = - Modifier.offset { IntOffset(x = 0, y = overscrollOffset.value.roundToInt()) } + // Create a LayoutModifierNode that offsets by overscrollOffset.value + override val node: DelegatableNode = + object : Modifier.Node(), LayoutModifierNode { + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { + val offsetValue = IntOffset(x = 0, y = overscrollOffset.value.roundToInt()) + placeable.placeRelativeWithLayer(offsetValue.x, offsetValue.y) + } + } + } } val offset = remember { mutableStateOf(0f) } @@ -184,14 +207,13 @@ fun OverscrollWithDraggable_After() { val minPosition = -1000f val maxPosition = 1000f - val overscrollEffect = ScrollableDefaults.overscrollEffect() + val overscrollEffect = rememberOverscrollEffect() val draggableState = rememberDraggableState { delta -> // Horizontal, so convert the delta to a horizontal offset val deltaAsOffset = Offset(delta, 0f) - // Wrap the original logic inside applyToScroll - overscrollEffect.applyToScroll(deltaAsOffset, NestedScrollSource.UserInput) { - remainingOffset -> + + val performDrag: (Offset) -> Offset = { remainingOffset -> val remainingDelta = remainingOffset.x val newPosition = (dragPosition + remainingDelta).coerceIn(minPosition, maxPosition) // Calculate how much delta we have consumed @@ -200,6 +222,13 @@ fun OverscrollWithDraggable_After() { // Return how much offset we consumed, so that we can show overscroll for what is left Offset(consumed, 0f) } + + if (overscrollEffect != null) { + // Wrap the original logic inside applyToScroll + overscrollEffect.applyToScroll(deltaAsOffset, NestedScrollSource.UserInput, performDrag) + } else { + performDrag(deltaAsOffset) + } } Box( @@ -211,7 +240,7 @@ fun OverscrollWithDraggable_After() { draggableState, orientation = Orientation.Horizontal, onDragStopped = { - overscrollEffect.applyToFling(Velocity(it, 0f)) { velocity -> + overscrollEffect?.applyToFling(Velocity(it, 0f)) { velocity -> if (dragPosition == minPosition || dragPosition == maxPosition) { // If we are at the min / max bound, give overscroll all of the velocity Velocity.Zero @@ -230,3 +259,40 @@ fun OverscrollWithDraggable_After() { Text("Drag position $dragPosition") } } + +@Sampled +@Composable +fun OverscrollRenderedOnTopOfLazyListDecorations() { + val items = remember { (1..100).toList() } + val state = rememberLazyListState() + val overscroll = rememberOverscrollEffect() + // Create a wrapped version of the above overscroll effect that does not draw. This will be + // used inside LazyColumn to provide events to overscroll, without letting LazyColumn draw the + // overscroll effect internally. + val overscrollWithoutDrawing = overscroll?.withoutDrawing() + LazyColumn( + content = { items(items) { Text("Item $it") } }, + state = state, + modifier = + Modifier.size(300.dp) + .clip(RectangleShape) + // Manually render the overscroll on top of the lazy list _and_ the 'decorations' we + // are + // manually drawing, to make sure they will also be included in the overscroll + // effect. + .overscroll(overscroll) + .drawBehind { + state.layoutInfo.visibleItemsInfo.drop(1).forEach { info -> + val verticalOffset = info.offset.toFloat() + drawLine( + color = Color.Red, + start = Offset(0f, verticalOffset), + end = Offset(size.width, verticalOffset) + ) + } + }, + // Pass the overscroll effect that does not draw inside the LazyList to receive overscroll + // events + overscrollEffect = overscrollWithoutDrawing + ) +} diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/AndroidEmbeddedExternalSurfaceTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/AndroidEmbeddedExternalSurfaceTest.kt index c2691df156272..fa69c9b405f38 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/AndroidEmbeddedExternalSurfaceTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/AndroidEmbeddedExternalSurfaceTest.kt @@ -21,7 +21,13 @@ import android.graphics.PorterDuff import android.os.Build import android.view.Surface import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.ReusableContentHost import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -49,6 +55,8 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -195,6 +203,98 @@ class AndroidEmbeddedExternalSurfaceTest { } } + @Test + fun testOnSurfaceReused() { + var surfaceCreatedCount = 0 + var surfaceDestroyedCount = 0 + var onInitCount = 0 + var active by mutableStateOf(true) + + // NOTE: TextureView only destroys the surface when TextureView is detached from + // the window, and only creates when it gets attached to the window + rule.setContent { + ReusableContentHost(active) { + AndroidEmbeddedExternalSurface(modifier = Modifier.size(size)) { + onInitCount++ + onSurface { surface, _, _ -> + assert(isActive) + surfaceCreatedCount++ + surface.onDestroyed { surfaceDestroyedCount++ } + } + } + } + } + + rule.runOnIdle { + assertEquals(1, onInitCount) + assertEquals(1, surfaceCreatedCount) + assertEquals(0, surfaceDestroyedCount) + } + + // Deactivate and reactivate the content to simulate reuse in lazy layouts + rule.runOnIdle { active = false } + rule.runOnIdle { active = true } + + rule.runOnIdle { + assertEquals(2, onInitCount) + assertEquals(2, surfaceCreatedCount) + assertEquals(1, surfaceDestroyedCount) + } + } + + @Test + fun testReuseInLazyColumn() { + var surfaceCreatedCount = 0 + var surfaceDestroyedCount = 0 + var onInitCount = 0 + lateinit var state: LazyListState + rule.setContent { + state = rememberLazyListState() + LazyColumn(Modifier.fillMaxWidth().height(size), state = state) { + items(3) { + AndroidEmbeddedExternalSurface(modifier = Modifier.size(size)) { + onInitCount++ + onSurface { surface, _, _ -> + assert(isActive) + surfaceCreatedCount++ + surface.onDestroyed { surfaceDestroyedCount++ } + } + } + } + } + } + + rule.runOnIdle { + assertEquals(1, onInitCount) + assertEquals(1, surfaceCreatedCount) + assertEquals(0, surfaceDestroyedCount) + } + + rule.runOnIdle { + runBlocking { + state.scrollToItem(1) // Item 0 should now be kept for reuse + } + } + + rule.runOnIdle { + assertEquals(2, onInitCount) + assertEquals(2, surfaceCreatedCount) + assertEquals(1, surfaceDestroyedCount) + } + + rule.runOnIdle { + runBlocking { + state.scrollToItem(2) // Item 2 should reuse the item 0 slot + } + } + + rule.runOnIdle { + assertEquals(3, onInitCount) + assertEquals(3, surfaceCreatedCount) + assertEquals(2, surfaceDestroyedCount) + } + } + @Test fun testRender() { var surfaceRef: Surface? = null diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/OverscrollTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/OverscrollTest.kt index 0ad36466ea6d4..47bf710365fef 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/OverscrollTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/OverscrollTest.kt @@ -26,10 +26,14 @@ import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.testutils.assertPixelColor import androidx.compose.testutils.assertPixels import androidx.compose.ui.Alignment @@ -39,15 +43,20 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectableValue import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.platform.isDebugInspectorInfoEnabled import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.ComposeContentTestRule @@ -57,7 +66,6 @@ import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.swipeWithVelocity -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -68,6 +76,7 @@ import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlin.math.abs import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -85,10 +94,172 @@ class OverscrollTest { fun before() { // if we don't do it the overscroll effect will not even start. animationScaleRule.setAnimationDurationScale(1f) + isDebugInspectorInfoEnabled = true + } + + @After + fun after() { + isDebugInspectorInfoEnabled = false } private val boxTag = "box" + @Test + fun modifierInspectorInfo() { + rule.setContent { + val modifier = Modifier.overscroll(rememberOverscrollEffect()) as InspectableValue + assertThat(modifier.nameFallback).isEqualTo("overscroll") + assertThat(modifier.valueOverride).isNull() + assertThat(modifier.inspectableElements.map { it.name }.asIterable()) + .containsExactly("overscrollEffect") + } + } + + @Test + fun modifierIsProducingEqualsModifiersForTheSameInput() { + var overscrollEffect: OverscrollEffect? = null + rule.setContent { overscrollEffect = rememberOverscrollEffect() } + + val first = Modifier.overscroll(overscrollEffect!!) + val second = Modifier.overscroll(overscrollEffect!!) + assertThat(first).isEqualTo(second) + } + + @Test + fun modifierAttachesNode() { + val overscrollEffect = TestOverscrollEffect() + + rule.setContent { Box(Modifier.overscroll(overscrollEffect)) } + + rule.runOnIdle { assertThat(overscrollEffect.node.node.isAttached).isTrue() } + } + + @Test + fun modifierUpdatesToNewNode() { + val overscrollEffect1 = TestOverscrollEffect() + val overscrollEffect2 = TestOverscrollEffect() + var effect by mutableStateOf(overscrollEffect1) + + rule.setContent { Box(Modifier.overscroll(effect)) } + + rule.runOnIdle { + assertThat(overscrollEffect1.node.node.isAttached).isTrue() + assertThat(overscrollEffect2.node.node.isAttached).isFalse() + effect = overscrollEffect2 + } + + // The old node should be detached, and the new one should be attached + rule.runOnIdle { + assertThat(overscrollEffect1.node.node.isAttached).isFalse() + assertThat(overscrollEffect2.node.node.isAttached).isTrue() + effect = overscrollEffect2 + } + } + + @Test + fun modifierDoesNotAddAlreadyAttachedNode() { + val overscrollEffect = TestOverscrollEffect() + class CustomDelegatingNode : DelegatingNode() { + init { + delegate(overscrollEffect.node) + } + } + + val element = + object : ModifierNodeElement() { + override fun create() = CustomDelegatingNode() + + override fun update(node: CustomDelegatingNode) {} + + override fun equals(other: Any?) = other === this + + override fun hashCode() = -1 + } + + var addOverscrollModifier by mutableStateOf(false) + + rule.setContent { + Box( + element.then( + if (addOverscrollModifier) Modifier.overscroll(overscrollEffect) else Modifier + ) + ) + } + + rule.runOnIdle { + assertThat(overscrollEffect.node.node.isAttached).isTrue() + addOverscrollModifier = true + } + + // Should not crash - the node should not be added by Modifier.overscroll + rule.waitForIdle() + } + + @Test + fun rememberOverscrollEffect_defaultValue() { + lateinit var effect: OverscrollEffect + rule.setContent { effect = rememberOverscrollEffect()!! } + rule.runOnIdle { + assertThat(effect).isInstanceOf(AndroidEdgeEffectOverscrollEffect::class.java) + } + } + + @Test + fun rememberOverscrollEffect_nullOverscrollFactory() { + var effect: OverscrollEffect? = null + rule.setContent { + CompositionLocalProvider(LocalOverscrollFactory provides null) { + effect = rememberOverscrollEffect() + } + } + rule.runOnIdle { assertThat(effect).isNull() } + } + + @Test + fun rememberOverscrollEffect_ChangeOverscrollFactory() { + lateinit var effect: OverscrollEffect + val movableContent = movableContentOf { effect = rememberOverscrollEffect()!! } + var setCustomFactory by mutableStateOf(false) + class CustomEffect : OverscrollEffect { + override val isInProgress = false + override val node = object : Modifier.Node() {} + + override fun applyToScroll( + delta: Offset, + source: NestedScrollSource, + performScroll: (Offset) -> Offset + ) = performScroll(delta) + + override suspend fun applyToFling( + velocity: Velocity, + performFling: suspend (Velocity) -> Velocity + ) {} + } + val customFactory = + object : OverscrollFactory { + override fun createOverscrollEffect(): OverscrollEffect = CustomEffect() + + override fun hashCode(): Int = -1 + + override fun equals(other: Any?) = other === this + } + rule.setContent { + if (setCustomFactory) { + CompositionLocalProvider( + LocalOverscrollFactory provides customFactory, + content = movableContent + ) + } else { + movableContent() + } + } + rule.runOnIdle { + assertThat(effect).isInstanceOf(AndroidEdgeEffectOverscrollEffect::class.java) + setCustomFactory = true + } + rule.runOnIdle { assertThat(effect).isInstanceOf(CustomEffect::class.java) } + } + @Test fun overscrollEffect_scrollable_drag() { testDrag(reverseDirection = false) @@ -304,23 +475,6 @@ class OverscrollTest { rule.runOnIdle { assertThat(controller.isInProgressCallCount).isEqualTo(2) } } - @Test - fun modifierIsProducingEqualsModifiersForTheSameInput() { - var overscrollEffect: OverscrollEffect? = null - rule.setContent { - overscrollEffect = - AndroidEdgeEffectOverscrollEffect( - LocalView.current.context, - LocalDensity.current, - OverscrollConfiguration(Color.Gray) - ) - } - - val first = Modifier.overscroll(overscrollEffect!!) - val second = Modifier.overscroll(overscrollEffect!!) - assertThat(first).isEqualTo(second) - } - @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O, maxSdkVersion = Build.VERSION_CODES.R) fun glowOverscroll_doesNotClip() { @@ -800,7 +954,7 @@ class OverscrollTest { lateinit var effect: OverscrollEffect rule.setContent { Box { - effect = rememberOverscrollEffect() + effect = rememberOverscrollEffect()!! Box(Modifier.overscroll(effect).size(0.dp)) } } @@ -833,7 +987,7 @@ class OverscrollTest { @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) fun notAttachedEffectIsNotConsumingOffsetsAndVelocity() { lateinit var effect: OverscrollEffect - rule.setContent { effect = rememberOverscrollEffect() } + rule.setContent { effect = rememberOverscrollEffect()!! } rule.runOnIdle { repeat(2) { @@ -1017,6 +1171,145 @@ class OverscrollTest { rule.runOnIdle { assertThat(inspectableConnection.preScrollVelocity.y).isEqualTo(0) } } + @Test + fun overscrollEffect_withoutDrawing_preDrag() { + var acummulatedScroll = 0f + val controller = TestOverscrollEffect(consumePreCycles = true) + val withoutDrawing = controller.withoutDrawing() + val scrollableState = ScrollableState { delta -> + acummulatedScroll += delta + delta + } + val viewConfig = + rule.setOverscrollContentAndReturnViewConfig( + scrollableState = scrollableState, + overscrollEffect = withoutDrawing + ) + + rule.onNodeWithTag(boxTag).performTouchInput { + down(center) + moveBy(Offset(1000f, 0f)) + } + + rule.runOnIdle { + val slop = viewConfig.touchSlop + // since we consume 1/10 of the delta in the pre scroll during overscroll, expect 9/10 + assertThat(abs(acummulatedScroll)).isWithin(0.1f).of((1000f - slop) * 9 / 10) + + assertThat(controller.lastPreScrollDelta).isEqualTo(Offset(1000f - slop, 0f)) + assertThat(controller.lastNestedScrollSource).isEqualTo(NestedScrollSource.UserInput) + + // We should not be drawn + assertThat(controller.drawCallsCount).isEqualTo(0) + } + } + + @Test + fun overscrollEffect_withoutDrawing_preFling() { + var acummulatedScroll = 0f + var lastFlingReceived = 0f + val controller = TestOverscrollEffect(consumePreCycles = true) + val withoutDrawing = controller.withoutDrawing() + val scrollableState = ScrollableState { delta -> + acummulatedScroll += delta + delta + } + val flingBehavior = + object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + lastFlingReceived = initialVelocity + return initialVelocity + } + } + rule.setOverscrollContentAndReturnViewConfig( + scrollableState = scrollableState, + overscrollEffect = withoutDrawing, + flingBehavior = flingBehavior + ) + + rule.onNodeWithTag(boxTag).performTouchInput { + swipeWithVelocity(center, centerRight, endVelocity = 3000f) + } + + rule.runOnIdle { + assertThat(abs(controller.preFlingVelocity.x)).isWithin(0.1f).of(3000f) + assertThat(abs(lastFlingReceived)).isWithin(0.1f).of(3000f * 9 / 10) + + // We should not be drawn + assertThat(controller.drawCallsCount).isEqualTo(0) + } + } + + @Test + fun overscrollEffect_withoutEventHandling_drag() { + var acummulatedScroll = 0f + val controller = TestOverscrollEffect(consumePreCycles = true) + val withoutEventHandling = controller.withoutEventHandling() + val scrollableState = ScrollableState { delta -> + acummulatedScroll += delta + delta + } + val viewConfig = + rule.setOverscrollContentAndReturnViewConfig( + scrollableState = scrollableState, + overscrollEffect = withoutEventHandling + ) + + // We should still be drawn + rule.waitUntil { controller.drawCallsCount == 1 } + + rule.onNodeWithTag(boxTag).performTouchInput { + down(center) + moveBy(Offset(1000f, 0f)) + } + + rule.runOnIdle { + val slop = viewConfig.touchSlop + // Overscroll should not have handled these events + assertThat(abs(acummulatedScroll)).isWithin(0.1f).of(1000f - slop) + + assertThat(controller.lastPreScrollDelta).isEqualTo(Offset.Zero) + assertThat(controller.lastNestedScrollSource).isNull() + } + } + + @Test + fun overscrollEffect_withoutEventHandling_fling() { + var acummulatedScroll = 0f + var lastFlingReceived = 0f + val controller = TestOverscrollEffect(consumePreCycles = true) + val withoutEventHandling = controller.withoutEventHandling() + val scrollableState = ScrollableState { delta -> + acummulatedScroll += delta + delta + } + val flingBehavior = + object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + lastFlingReceived = initialVelocity + return initialVelocity + } + } + rule.setOverscrollContentAndReturnViewConfig( + scrollableState = scrollableState, + overscrollEffect = withoutEventHandling, + flingBehavior = flingBehavior + ) + + // We should still be drawn + rule.waitUntil { controller.drawCallsCount == 1 } + + rule.onNodeWithTag(boxTag).performTouchInput { + swipeWithVelocity(center, centerRight, endVelocity = 3000f) + } + + rule.runOnIdle { + // Overscroll should not have handled these events + assertThat(abs(controller.preFlingVelocity.x)).isEqualTo(0) + assertThat(abs(lastFlingReceived)).isWithin(0.1f).of(3000f) + } + } + private fun assertSingleAxisValue(mainAxis: Float, crossAxis: Float) { assertThat(abs(mainAxis)).isGreaterThan(0) assertThat(crossAxis).isEqualTo(0) @@ -1074,7 +1367,13 @@ class OverscrollTest { return animationRunning } - override val effectModifier: Modifier = Modifier.drawBehind { drawCallsCount += 1 } + override val node: DelegatableNode = + object : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + drawCallsCount += 1 + drawContent() + } + } } fun testDrag(reverseDirection: Boolean) { @@ -1172,7 +1471,7 @@ class OverscrollTest { modifier = Modifier.testTag(boxTag) .size(100.dp) - .overscroll(ScrollableDefaults.overscrollEffect()) + .overscroll(rememberOverscrollEffect()) .drawBehind { drawCount++ } ) } @@ -1300,5 +1599,5 @@ private class OffsetOverscrollEffectCounter : OverscrollEffect { } override val isInProgress: Boolean = false - override val effectModifier: Modifier = Modifier.offset { IntOffset(x = 0, y = 0) } + override val node: DelegatableNode = object : Modifier.Node() {} } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt index 0eab2a64ee13a..728d34b0bf059 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt @@ -46,6 +46,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.testutils.assertPixels import androidx.compose.testutils.assertShape +import androidx.compose.testutils.first +import androidx.compose.testutils.toList import androidx.compose.ui.Modifier import androidx.compose.ui.MotionDurationScale import androidx.compose.ui.draw.drawBehind @@ -53,6 +55,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.layout.IntrinsicMeasurable import androidx.compose.ui.layout.IntrinsicMeasureScope import androidx.compose.ui.layout.Layout @@ -63,6 +67,8 @@ import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.OnRemeasuredModifier import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.platform.InspectableValue import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection @@ -93,6 +99,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection.Ltr import androidx.compose.ui.unit.LayoutDirection.Rtl +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.test.filters.LargeTest import androidx.test.filters.MediumTest @@ -653,7 +660,7 @@ class ScrollTest(private val config: Config) { } @Test - fun testInspectorValue() { + fun testInspectorValue_withoutOverscrollParameter() { val state = ScrollState(initial = 0) rule.setContent { val modifier = @@ -662,16 +669,49 @@ class ScrollTest(private val config: Config) { Horizontal -> Modifier.horizontalScroll(state) } as InspectableValue - assertThat(modifier.nameFallback).isEqualTo("scroll") + val expectedName = + when (config.orientation) { + Vertical -> "verticalScroll" + Horizontal -> "horizontalScroll" + } + assertThat(modifier.nameFallback).isEqualTo(expectedName) assertThat(modifier.valueOverride).isNull() assertThat(modifier.inspectableElements.map { it.name }.asIterable()) + .containsExactly("state", "enabled", "flingBehavior", "reverseScrolling") + } + } + + @Test + fun testInspectorValue_withOverscrollParameter() { + val state = ScrollState(initial = 0) + rule.setContent { + val modifiers = + when (config.orientation) { + Vertical -> Modifier.verticalScroll(state, overscrollEffect = null) + Horizontal -> Modifier.horizontalScroll(state, overscrollEffect = null) + }.toList() + + val scrollableContainer = modifiers[0] as InspectableValue + val scroll = modifiers[1] as InspectableValue + + assertThat(scrollableContainer.nameFallback).isEqualTo("scrollingContainer") + assertThat(scrollableContainer.valueOverride).isNull() + assertThat(scrollableContainer.inspectableElements.map { it.name }.asIterable()) .containsExactly( "state", + "orientation", + "enabled", "reverseScrolling", "flingBehavior", - "isScrollable", - "isVertical" + "interactionSource", + "bringIntoViewSpec", + "overscrollEffect" ) + + assertThat(scroll.nameFallback).isEqualTo("scroll") + assertThat(scroll.valueOverride).isNull() + assertThat(scroll.inspectableElements.map { it.name }.asIterable()) + .containsExactly("state", "reverseScrolling", "isVertical") } } @@ -1135,6 +1175,79 @@ class ScrollTest(private val config: Config) { } } + @Test + fun customOverscroll() { + val containerSize = with(rule.density) { 100.toDp() } + val contentSize = with(rule.density) { 110.toDp() } + val scrollState = ScrollState(initial = 0) + val overscroll = TestOverscrollEffect() + rule.setContent { + Box { + Box(Modifier.size(containerSize, containerSize)) { + when (config.orientation) { + Vertical -> { + Column( + Modifier.testTag(scrollerTag) + .verticalScroll( + state = scrollState, + overscrollEffect = overscroll + ) + ) { + Box(Modifier.height(contentSize).fillMaxWidth()) + } + } + Horizontal -> { + CompositionLocalProvider( + LocalLayoutDirection provides config.layoutDirection + ) { + Row( + Modifier.testTag(scrollerTag) + .horizontalScroll( + state = scrollState, + overscrollEffect = overscroll + ) + ) { + Box(Modifier.width(contentSize).fillMaxHeight()) + } + } + } + } + } + } + } + + // The overscroll modifier should be added / drawn + rule.runOnIdle { assertThat(overscroll.drawCalled).isTrue() } + + // Swipe past the end + rule.onNodeWithTag(scrollerTag).performTouchInput { configAwareSwipe() } + + rule.runOnIdle { + assertThat(scrollState.value).isEqualTo(10) + // The swipe will result in multiple scroll deltas + assertThat(overscroll.applyToScrollCalledCount).isGreaterThan(1) + assertThat(overscroll.applyToFlingCalledCount).isEqualTo(1) + when (config.orientation) { + Vertical -> { + assertThat(overscroll.scrollOverscrollDelta.y).isLessThan(0) + assertThat(overscroll.flingOverscrollVelocity.y).isLessThan(0) + } + Horizontal -> { + when (config.layoutDirection) { + Ltr -> { + assertThat(overscroll.scrollOverscrollDelta.x).isLessThan(0) + assertThat(overscroll.flingOverscrollVelocity.x).isLessThan(0) + } + Rtl -> { + assertThat(overscroll.scrollOverscrollDelta.x).isGreaterThan(0) + assertThat(overscroll.flingOverscrollVelocity.x).isGreaterThan(0) + } + } + } + } + } + } + private fun Modifier.intrinsicMainAxisSize(size: IntrinsicSize): Modifier = if (config.orientation == Horizontal) { width(size) @@ -1437,4 +1550,49 @@ class ScrollTest(private val config: Config) { override val scaleFactor: Float get() = 0f } + + private class TestOverscrollEffect : OverscrollEffect { + var applyToScrollCalledCount: Int = 0 + private set + + var applyToFlingCalledCount: Int = 0 + private set + + var scrollOverscrollDelta: Offset = Offset.Zero + private set + + var flingOverscrollVelocity: Velocity = Velocity.Zero + private set + + var drawCalled: Boolean = false + + override fun applyToScroll( + delta: Offset, + source: NestedScrollSource, + performScroll: (Offset) -> Offset + ): Offset { + applyToScrollCalledCount++ + val consumed = performScroll(delta) + scrollOverscrollDelta = delta - consumed + return consumed + } + + override suspend fun applyToFling( + velocity: Velocity, + performFling: suspend (Velocity) -> Velocity + ) { + applyToFlingCalledCount++ + val consumed = performFling(velocity) + flingOverscrollVelocity = velocity - consumed + } + + override val isInProgress: Boolean = false + override val node: DelegatableNode = + object : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + drawContent() + drawCalled = true + } + } + } } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableTest.kt index fb712159bbd6d..fbf18b318fdfa 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableTest.kt @@ -52,6 +52,7 @@ import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.text.matchers.isZero import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.currentComposer import androidx.compose.runtime.getValue @@ -88,6 +89,7 @@ import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.TraversableNode import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.InspectableValue +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.isDebugInspectorInfoEnabled @@ -115,6 +117,7 @@ import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.swipeWithVelocity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times @@ -2722,9 +2725,7 @@ class ScrollableTest { rule.setContent { counter.value // just to trigger recomposition materialized = - currentComposer.materialize( - Modifier.scrollable(state, Orientation.Vertical, NoOpOverscrollEffect) - ) + currentComposer.materialize(Modifier.scrollable(state, Orientation.Vertical, null)) } lateinit var first: Modifier @@ -3161,6 +3162,41 @@ class ScrollableTest { .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollByOffset)) } + @Test + fun onDensityChange_shouldUpdateFlingBehavior() { + var density by mutableStateOf(rule.density) + var flingDelta = 0f + val fixedSize = 400 + rule.setContent { + CompositionLocalProvider(LocalDensity provides density) { + Box( + Modifier.size(with(density) { fixedSize.toDp() }) + .testTag(scrollableBoxTag) + .scrollable( + state = + rememberScrollableState { + flingDelta += it + it + }, + orientation = Orientation.Vertical + ) + ) + } + } + + rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeUp() } + + rule.waitForIdle() + + density = Density(rule.density.density * 2f) + val previousDelta = flingDelta + flingDelta = 0.0f + + rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeUp() } + + rule.runOnIdle { assertThat(flingDelta).isNotEqualTo(previousDelta) } + } + private fun setScrollableContent(scrollableModifierFactory: @Composable () -> Modifier) { rule.setContentAndGetScope { Box { diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt new file mode 100644 index 0000000000000..1b359e5318836 --- /dev/null +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt @@ -0,0 +1,312 @@ +/* + * 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. + */ +package androidx.compose.foundation + +import androidx.compose.foundation.OverscrollTest.TestOverscrollEffect +import androidx.compose.foundation.gestures.Orientation.Horizontal +import androidx.compose.foundation.gestures.Orientation.Vertical +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.testutils.assertShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectableValue +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.isDebugInspectorInfoEnabled +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.unit.LayoutDirection.Ltr +import androidx.compose.ui.unit.LayoutDirection.Rtl +import androidx.compose.ui.unit.dp +import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@MediumTest +class ScrollingContainerTest { + @get:Rule val rule = createComposeRule() + + @Before + fun before() { + isDebugInspectorInfoEnabled = true + } + + @After + fun after() { + isDebugInspectorInfoEnabled = false + } + + @Test + fun testInspectorValue() { + rule.setContent { + val modifier = + Modifier.scrollingContainer( + rememberScrollState(), + orientation = Horizontal, + enabled = true, + reverseScrolling = false, + flingBehavior = null, + interactionSource = null, + overscrollEffect = null, + bringIntoViewSpec = null + ) as InspectableValue + assertThat(modifier.nameFallback).isEqualTo("scrollingContainer") + assertThat(modifier.valueOverride).isNull() + assertThat(modifier.inspectableElements.map { it.name }.asIterable()) + .containsExactly( + "state", + "orientation", + "enabled", + "reverseScrolling", + "flingBehavior", + "interactionSource", + "overscrollEffect", + "bringIntoViewSpec" + ) + } + } + + @SdkSuppress(minSdkVersion = 26) + @Test + fun clipUpdatesWhenOrientationChanges() { + var orientation by mutableStateOf(Horizontal) + rule.setContent { + val scrollState = rememberScrollState(20) + Box(Modifier.size(60.dp).testTag("container").background(Color.Gray)) { + Box( + Modifier.padding(20.dp) + .fillMaxSize() + .scrollingContainer( + state = scrollState, + orientation = orientation, + enabled = true, + reverseScrolling = false, + flingBehavior = null, + interactionSource = null, + overscrollEffect = null + ) + ) { + repeat(4) { Box(Modifier.size(20.dp).drawOutsideOfBounds()) } + } + } + } + + rule + .onNodeWithTag("container") + .captureToImage() + .assertShape( + density = rule.density, + shape = RectangleShape, + shapeColor = Color.Red, + backgroundColor = Color.Gray, + horizontalPadding = 20.dp, + verticalPadding = 0.dp + ) + + rule.runOnIdle { orientation = Vertical } + + rule + .onNodeWithTag("container") + .captureToImage() + .assertShape( + density = rule.density, + shape = RectangleShape, + shapeColor = Color.Red, + backgroundColor = Color.Gray, + horizontalPadding = 0.dp, + verticalPadding = 20.dp + ) + } + + @Test + fun layoutDirectionChange_updatesScrollDirection() { + val size = with(rule.density) { 100.toDp() } + var scrollAmount = 0f + val scrollState = ScrollableState { + scrollAmount = (scrollAmount + it).coerceIn(0f, 10f) + it + } + var layoutDirection by mutableStateOf(Ltr) + rule.setContent { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + Box { + Box( + Modifier.size(size) + .testTag("container") + .scrollingContainer( + state = scrollState, + orientation = Horizontal, + enabled = true, + reverseScrolling = false, + flingBehavior = null, + interactionSource = null, + overscrollEffect = null + ) + ) + } + } + } + + rule.onNodeWithTag("container").performTouchInput { swipeLeft() } + + rule.runOnIdle { + assertThat(scrollAmount).isEqualTo(10f) + layoutDirection = Rtl + } + + rule.onNodeWithTag("container").performTouchInput { swipeLeft() } + + // Now that layout direction changed, we should go back to 0 + rule.runOnIdle { assertThat(scrollAmount).isEqualTo(0f) } + } + + @Test + fun attachesOverscrollEffectNode() { + val overscrollEffect = TestOverscrollEffect() + + rule.setContent { + Box( + Modifier.scrollingContainer( + rememberScrollState(), + orientation = Horizontal, + enabled = true, + reverseScrolling = false, + flingBehavior = null, + interactionSource = null, + overscrollEffect = overscrollEffect, + bringIntoViewSpec = null + ) + ) + } + + rule.runOnIdle { assertThat(overscrollEffect.node.node.isAttached).isTrue() } + } + + @Test + fun updatesToNewOverscrollEffectNode() { + val overscrollEffect1 = TestOverscrollEffect() + val overscrollEffect2 = TestOverscrollEffect() + var effect by mutableStateOf(overscrollEffect1) + + rule.setContent { + Box( + Modifier.scrollingContainer( + rememberScrollState(), + orientation = Horizontal, + enabled = true, + reverseScrolling = false, + flingBehavior = null, + interactionSource = null, + overscrollEffect = effect, + bringIntoViewSpec = null + ) + ) + } + + rule.runOnIdle { + assertThat(overscrollEffect1.node.node.isAttached).isTrue() + assertThat(overscrollEffect2.node.node.isAttached).isFalse() + effect = overscrollEffect2 + } + + // The old node should be detached, and the new one should be attached + rule.runOnIdle { + assertThat(overscrollEffect1.node.node.isAttached).isFalse() + assertThat(overscrollEffect2.node.node.isAttached).isTrue() + effect = overscrollEffect2 + } + } + + @Test + fun doesNotAddAlreadyAttachedOverscrollEffectNode() { + val overscrollEffect = TestOverscrollEffect() + class CustomDelegatingNode : DelegatingNode() { + init { + delegate(overscrollEffect.node) + } + } + + val element = + object : ModifierNodeElement() { + override fun create() = CustomDelegatingNode() + + override fun update(node: CustomDelegatingNode) {} + + override fun equals(other: Any?) = other === this + + override fun hashCode() = -1 + } + + var addScrollingContainer by mutableStateOf(false) + + rule.setContent { + Box( + element.then( + if (addScrollingContainer) + Modifier.scrollingContainer( + rememberScrollState(), + orientation = Horizontal, + enabled = true, + reverseScrolling = false, + flingBehavior = null, + interactionSource = null, + overscrollEffect = overscrollEffect, + bringIntoViewSpec = null + ) + else Modifier + ) + ) + } + + rule.runOnIdle { + assertThat(overscrollEffect.node.node.isAttached).isTrue() + addScrollingContainer = true + } + + // Should not crash - the node should not be added by Modifier.scrollingContainer + rule.waitForIdle() + } + + private fun Modifier.drawOutsideOfBounds() = drawBehind { + val inflate = 20.dp.roundToPx().toFloat() + drawRect( + Color.Red, + Offset(-inflate, -inflate), + Size(size.width + inflate * 2, size.height + inflate * 2) + ) + } +} diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/StretchOverscrollIntegrationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/StretchOverscrollIntegrationTest.kt index 6ab3b281fe72b..5f4624161e507 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/StretchOverscrollIntegrationTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/StretchOverscrollIntegrationTest.kt @@ -1111,8 +1111,7 @@ class StretchOverscrollIntegrationTest { val state = TestState() rule.setContent { WithTouchSlop(touchSlop = 0f) { - state.overscroll = - ScrollableDefaults.overscrollEffect() as AndroidEdgeEffectOverscrollEffect + state.overscroll = rememberOverscrollEffect() as AndroidEdgeEffectOverscrollEffect Box( Modifier.testTag(OverscrollBox) .size(250.dp) diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/TransformableTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/TransformableTest.kt index 2a74fc4c50bc9..e147436ccd16a 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/TransformableTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/TransformableTest.kt @@ -17,6 +17,7 @@ package androidx.compose.foundation import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.SCROLL_FACTOR import androidx.compose.foundation.gestures.TransformableState import androidx.compose.foundation.gestures.animateBy import androidx.compose.foundation.gestures.animatePanBy @@ -37,15 +38,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.InspectableValue import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.isDebugInspectorInfoEnabled import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.ScrollWheel import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performMultiModalInput import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.pinch +import androidx.compose.ui.test.withKeysDown +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -789,9 +797,158 @@ class TransformableTest { } } + @OptIn(ExperimentalTestApi::class) + @Test + fun transformable_ctrlAndMouseScrollUp_doesZoomIn() { + var cumulativeScale = 1.0f + + setTransformableContent { + Modifier.transformable( + state = rememberTransformableState { zoom, _, _ -> cumulativeScale *= zoom } + ) + } + + rule.onNodeWithTag(TEST_TAG).performMultiModalInput { + key { + withKeysDown(listOf(Key.CtrlLeft)) { + mouse { scroll(scrollDeltaFor2xZoom, scrollWheel = ScrollWheel.Vertical) } + } + } + } + + rule.runOnIdle { + assertWithMessage("Should have scaled down at least 2x") + .that(cumulativeScale) + .isAtLeast(2f) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun transformable_ctrlAndMouseScrollDown_doesZoomOut() { + var cumulativeScale = 1.0f + + setTransformableContent { + Modifier.transformable( + state = rememberTransformableState { zoom, _, _ -> cumulativeScale *= zoom } + ) + } + + rule.onNodeWithTag(TEST_TAG).performMultiModalInput { + key { + withKeysDown(listOf(Key.CtrlLeft)) { + mouse { scroll(-scrollDeltaFor2xZoom, scrollWheel = ScrollWheel.Vertical) } + } + } + } + + rule.runOnIdle { + assertWithMessage("Should have scaled down at least 0.5x") + .that(cumulativeScale) + .isAtMost(0.5f) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun transformableInsideScroll_ctrlAndMouseScroll_doesZoomNoScroll() { + var cumulativeScale = 1.0f + val scrollState = ScrollState(0) + + rule.setContentAndGetScope { + Column(modifier = Modifier.size(100.dp).verticalScroll(scrollState)) { + Box( + Modifier.size(100.dp) + .testTag(TEST_TAG) + .transformable( + state = + rememberTransformableState { zoom, _, _ -> cumulativeScale *= zoom } + ) + ) + Box(Modifier.size(100.dp)) + } + } + + rule.onNodeWithTag(TEST_TAG).performMultiModalInput { + key { + withKeysDown(listOf(Key.CtrlLeft)) { + mouse { scroll(-scrollDeltaFor2xZoom, scrollWheel = ScrollWheel.Vertical) } + } + } + } + + rule.runOnIdle { + assertWithMessage("Should have scaled down at least 0.5x") + .that(cumulativeScale) + .isAtMost(0.5f) + + assertWithMessage("Should not scroll").that(scrollState.value).isEqualTo(0) + } + } + + @Test + fun transformableInsideScroll_mouseScrollOnly_doesScrollNoZoom() { + var cumulativeScale = 1.0f + val scrollState = ScrollState(0) + val scrolledDelta = 1f + + rule.setContentAndGetScope { + Column(modifier = Modifier.size(100.dp).verticalScroll(scrollState)) { + Box( + Modifier.size(100.dp) + .testTag(TEST_TAG) + .transformable( + state = + rememberTransformableState { zoom, _, _ -> cumulativeScale *= zoom } + ) + ) + Box(Modifier.size(100.dp)) + } + } + + rule.onNodeWithTag(TEST_TAG).performMouseInput { + scroll(scrolledDelta, scrollWheel = ScrollWheel.Vertical) + } + + rule.runOnIdle { + assertWithMessage("Should have scaled down at least 0.5x") + .that(cumulativeScale) + .isEqualTo(1f) + + assertWithMessage("Should have scrolled").that(scrollState.value).isGreaterThan(0) + } + } + + @Test + fun transformable_mouseScrollOnly_noZoom() { + var cumulativeScale = 1.0f + + setTransformableContent { + Modifier.transformable( + state = rememberTransformableState { zoom, _, _ -> cumulativeScale *= zoom } + ) + } + + rule.onNodeWithTag(TEST_TAG).performMouseInput { + scroll(scrollDeltaFor2xZoom, scrollWheel = ScrollWheel.Vertical) + } + + rule.runOnIdle { + assertWithMessage("Should have scaled down at least 2x") + .that(cumulativeScale) + .isEqualTo(1f) + } + } + private fun setTransformableContent(getModifier: @Composable () -> Modifier) { rule.setContentAndGetScope { Box(Modifier.size(600.dp).testTag(TEST_TAG).then(getModifier())) } } + + // The scrollDelta equal to 1f is converted to -64.dp of scroll distance. + // This is defined inAndroidScrollable.android.kt + // And zoom factor is computed by 2^(scrolled pixels / SCROLL_FACTOR). + private val Density.scrollDeltaFor2xZoom: Float + get() = SCROLL_FACTOR / -64.dp.toPx() } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableBackwardsCompatibleTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableBackwardsCompatibleTest.kt index e8dd00de7ac0e..569c9ef52f2ad 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableBackwardsCompatibleTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableBackwardsCompatibleTest.kt @@ -60,6 +60,7 @@ abstract class AnchoredDraggableBackwardsCompatibleTest(private val testNewBehav snapAnimationSpec: AnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec, decayAnimationSpec: DecayAnimationSpec = AnchoredDraggableDefaults.DecayAnimationSpec, + shouldCreateFling: Boolean = true ): Pair, Modifier> { val state = createAnchoredDraggableState( @@ -74,12 +75,16 @@ abstract class AnchoredDraggableBackwardsCompatibleTest(private val testNewBehav val modifier = if (testNewBehavior) { val flingBehavior = - anchoredDraggableFlingBehavior( - state, - density = rule.density, - positionalThreshold = positionalThreshold, - snapAnimationSpec = snapAnimationSpec - ) + if (shouldCreateFling) { + anchoredDraggableFlingBehavior( + state, + density = rule.density, + positionalThreshold = positionalThreshold, + snapAnimationSpec = snapAnimationSpec + ) + } else { + null + } createAnchoredDraggableModifier( state = state, reverseDirection = reverseDirection, @@ -126,6 +131,7 @@ abstract class AnchoredDraggableBackwardsCompatibleTest(private val testNewBehav "The velocity threshold resolved to $resolvedVelocityThreshold, but velocity " + "thresholds are not configurable with testNewBehavior=true." } + @Suppress("DEPRECATION") /* confirmValueChange is deprecated */ when (anchors) { null -> AnchoredDraggableState( diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableGestureTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableGestureTest.kt index 6e7154a183064..a37997f1a6d87 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableGestureTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableGestureTest.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.anchoredDraggable.AnchoredDraggableTestValue. import androidx.compose.foundation.anchoredDraggable.AnchoredDraggableTestValue.C import androidx.compose.foundation.background import androidx.compose.foundation.gestures.AnchoredDraggableDefaults +import androidx.compose.foundation.gestures.AnchoredDraggableMinFlingVelocity import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateTo @@ -71,7 +72,7 @@ import org.junit.runners.Parameterized @RunWith(Parameterized::class) @LargeTest -class AnchoredDraggableGestureTest(testNewBehavior: Boolean) : +class AnchoredDraggableGestureTest(val testNewBehavior: Boolean) : AnchoredDraggableBackwardsCompatibleTest(testNewBehavior) { private val AnchoredDraggableTestTag = "dragbox" @@ -746,6 +747,82 @@ class AnchoredDraggableGestureTest(testNewBehavior: Boolean) : assertThat(state.offset).isEqualTo(state.anchors.positionOf(C)) } + @Test + fun anchoredDraggable_onDensityChanges_swipeWithVelocityHigherThanThreshold() { + if (!testNewBehavior) return + val (state, modifier) = + createStateAndModifier( + initialValue = A, + orientation = Orientation.Horizontal, + shouldCreateFling = false + ) + + var density by mutableStateOf(rule.density) + val originalThreshold = AnchoredDraggableMinFlingVelocityPx * 1.1f + rule.setContent { + Box(Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalDensity provides density) { + Box( + Modifier.requiredSize(400.dp) + .testTag(AnchoredDraggableTestTag) + .then(modifier) + .onSizeChanged { layoutSize -> + val anchors = DraggableAnchors { + A at 0f + B at layoutSize.width / 2f + C at layoutSize.width.toFloat() + } + state.updateAnchors(anchors) + } + .offset { IntOffset(state.requireOffset().roundToInt(), 0) } + .background(Color.Red) + ) + } + } + } + var offsetDisplaced = 0.0f + rule.onNodeWithTag(AnchoredDraggableTestTag).performTouchInput { + offsetDisplaced = right / 2 + swipeWithVelocity( + start = Offset(left, 0f), + end = Offset(offsetDisplaced, 0f), + endVelocity = originalThreshold + ) + } + + rule.waitForIdle() + assertThat(state.currentValue).isEqualTo(B) + + rule.runOnIdle { + density = Density(density = density.density * 2f) // now threshold is higher + } + + rule.onNodeWithTag(AnchoredDraggableTestTag).performTouchInput { + swipeWithVelocity( + start = Offset(left, 0f), + end = Offset(offsetDisplaced, 0f), + endVelocity = originalThreshold + ) + } + + // will not advance, threshold grew because of density change + rule.waitForIdle() + assertThat(state.currentValue).isEqualTo(B) + + // now use the new threshold + rule.onNodeWithTag(AnchoredDraggableTestTag).performTouchInput { + swipeWithVelocity( + start = Offset(left, 0f), + end = Offset(offsetDisplaced, 0f), + endVelocity = with(density) { AnchoredDraggableMinFlingVelocity.toPx() } * 1.1f + ) + } + + // will advance correctly + rule.waitForIdle() + assertThat(state.currentValue).isEqualTo(C) + } + private val DefaultSnapAnimationSpec = tween() private class HandPumpTestFrameClock : MonotonicFrameClock { diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableOverscrollTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableOverscrollTest.kt index be8aeece6310c..e6ce5a273c91d 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableOverscrollTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableOverscrollTest.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.onNodeWithTag @@ -47,6 +48,7 @@ import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.test.filters.LargeTest import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import kotlin.math.abs import kotlin.math.roundToInt import kotlin.test.Test @@ -88,18 +90,72 @@ class AnchoredDraggableOverscrollTest(testNewBehavior: Boolean) : } } + val positionOfA = state.anchors.positionOf(A) + val maxBound = state.anchors.positionOf(B) + val overDrag = Offset(x = 100f, y = 0f) + + assertThat(state.currentValue).isEqualTo(A) + assertThat(state.offset).isEqualTo(positionOfA) + assertThat(overscrollEffect.scrollOverscrollDelta).isEqualTo(Offset.Zero) + + // drag to positionB + overDrag + rule.onNodeWithTag(AnchoredDraggableTestTag).performTouchInput { + down(Offset(0f, 0f)) + moveBy(Offset(x = maxBound + overDrag.x, y = 0f)) + up() + } + rule.waitForIdle() + + // assert the component settled at anchor B + assertThat(state.currentValue).isEqualTo(B) + assertThat(state.offset).isEqualTo(maxBound) + + // assert that applyToScroll was called and there is a remaining delta because of dragging + // out of bounds + assertThat(overscrollEffect.applyToScrollCalledCount).isEqualTo(1) + assertThat(overscrollEffect.scrollOverscrollDelta).isEqualTo(overDrag) + } + + private fun testDispatchesToOverscrollInOrientationOnlyWhenDraggedOutOfBounds( + orientation: Orientation + ) { + val overscrollEffect = TestOverscrollEffect() + val (state, modifier) = + createStateAndModifier( + initialValue = A, + anchors = + DraggableAnchors { + A at 0f + B at 250f + }, + orientation = orientation, + overscrollEffect = overscrollEffect + ) + rule.setContent { + WithTouchSlop(0f) { + Box(Modifier.fillMaxSize().overscroll(overscrollEffect)) { + Box( + Modifier.requiredSize(AnchoredDraggableBoxSize) + .testTag(AnchoredDraggableTestTag) + .then(modifier) + .offset { IntOffset(state.requireOffset().roundToInt(), 0) } + ) + } + } + } + val positionOfA = state.anchors.positionOf(A) val maxBound = state.anchors.positionOf(B) val overDrag = 100f assertThat(state.currentValue).isEqualTo(A) assertThat(state.offset).isEqualTo(positionOfA) - assertThat(overscrollEffect.scrollOverscrollDelta.x).isEqualTo(0f) + assertThat(overscrollEffect.scrollOverscrollDelta).isEqualTo(Offset.Zero) // drag to positionB + overDrag rule.onNodeWithTag(AnchoredDraggableTestTag).performTouchInput { down(Offset(0f, 0f)) - moveBy(Offset(x = maxBound + overDrag, y = 0f)) + moveBy(Offset(x = maxBound + overDrag, y = maxBound + overDrag)) up() } rule.waitForIdle() @@ -111,11 +167,29 @@ class AnchoredDraggableOverscrollTest(testNewBehavior: Boolean) : // assert that applyToScroll was called and there is a remaining delta because of dragging // out of bounds assertThat(overscrollEffect.applyToScrollCalledCount).isEqualTo(1) - assertThat(abs(overscrollEffect.scrollOverscrollDelta.x)).isEqualTo(overDrag) + assertWithMessage("overscrollDelta.x for orientation $orientation") + .that(overscrollEffect.scrollOverscrollDelta.x) + .isEqualTo(if (orientation == Orientation.Horizontal) overDrag else 0f) + assertWithMessage("overscrollDelta.y for orientation $orientation") + .that(overscrollEffect.scrollOverscrollDelta.y) + .isEqualTo(if (orientation == Orientation.Vertical) overDrag else 0f) + } + + @Test + fun anchoredDraggable_scrollOutOfBounds_dispatchesToOverscroll_inOrientationOnly_horizontal() { + testDispatchesToOverscrollInOrientationOnlyWhenDraggedOutOfBounds( + orientation = Orientation.Horizontal + ) } @Test - fun anchoredDraggable_swipeWithVelocity_haveVelocityForOverscroll() { + fun anchoredDraggable_scrollOutOfBounds_dispatchesToOverscroll_inOrientationOnly_vertical() { + testDispatchesToOverscrollInOrientationOnlyWhenDraggedOutOfBounds( + orientation = Orientation.Vertical + ) + } + + private fun testSwipeWithVelocityDispatchesToOverscroll(orientation: Orientation) { val endVelocity = 4000f val overscrollEffect = TestOverscrollEffect() val (state, modifier) = @@ -126,7 +200,7 @@ class AnchoredDraggableOverscrollTest(testNewBehavior: Boolean) : A at 0f B at 250f }, - orientation = Orientation.Horizontal, + orientation = orientation, overscrollEffect = overscrollEffect, decayAnimationSpec = SplineBasedFloatDecayAnimationSpec(rule.density).generateDecayAnimationSpec() @@ -154,9 +228,11 @@ class AnchoredDraggableOverscrollTest(testNewBehavior: Boolean) : assertThat(overscrollEffect.applyToFlingCalledCount).isEqualTo(0) rule.onNodeWithTag(AnchoredDraggableTestTag).performTouchInput { + val endPointX = if (orientation == Orientation.Horizontal) right else 0f + val endPointY = if (orientation == Orientation.Vertical) bottom else 0f swipeWithVelocity( start = Offset(left, 0f), - end = Offset(right / 2, 0f), + end = Offset(endPointX, endPointY), endVelocity = endVelocity ) } @@ -172,9 +248,24 @@ class AnchoredDraggableOverscrollTest(testNewBehavior: Boolean) : assertThat(overscrollEffect.applyToFlingCalledCount).isEqualTo(1) // [flingOverscrollVelocity] would be slightly less than [endVelocity] as one animation // frame would be executed, which consumes some velocity - assertThat(abs(overscrollEffect.flingOverscrollVelocity.x)) + assertWithMessage("flingOverscrollVelocity.x for orientation $orientation") + .that(abs(overscrollEffect.flingOverscrollVelocity.x)) .isWithin(endVelocity * 0.005f) - .of(endVelocity) + .of(if (orientation == Orientation.Horizontal) endVelocity else 0f) + assertWithMessage("flingOverscrollVelocity.y for orientation $orientation") + .that(abs(overscrollEffect.flingOverscrollVelocity.y)) + .isWithin(endVelocity * 0.005f) + .of(if (orientation == Orientation.Vertical) endVelocity else 0f) + } + + @Test + fun anchoredDraggable_swipeWithVelocity_haveVelocityForOverscroll_horizontal() { + testSwipeWithVelocityDispatchesToOverscroll(Orientation.Horizontal) + } + + @Test + fun anchoredDraggable_swipeWithVelocity_haveVelocityForOverscroll_vertical() { + testSwipeWithVelocityDispatchesToOverscroll(Orientation.Vertical) } @Test @@ -330,7 +421,7 @@ private class TestOverscrollEffect : OverscrollEffect { } override val isInProgress: Boolean = false - override val effectModifier: Modifier = Modifier + override val node: DelegatableNode = object : Modifier.Node() {} } private val NoOpDensity = diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt index f074dcf22fd0b..c97ee82b99670 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt @@ -1394,6 +1394,44 @@ class AnchoredDraggableStateTest(testNewBehavior: Boolean) : assertThat(state.offset).isEqualTo(200f) } + @Test + fun anchoredDraggable_fling_offsetPastHalfwayBetweenAnchors_beforePosThreshold_doesntAdvance() { + val velocityThreshold = with(rule.density) { 125.dp.toPx() } + val positionalThreshold: (Float) -> Float = { it * 0.9f } + val state = + createAnchoredDraggableState( + initialValue = A, + anchors = + DraggableAnchors { + A at 0f + B at 100f + C at 200f + }, + positionalThreshold = positionalThreshold + ) + val flingBehavior = + createAnchoredDraggableFlingBehavior( + state = state, + density = rule.density, + positionalThreshold = positionalThreshold, + snapAnimationSpec = tween() + ) + + state.dispatchRawDelta(80f) + + assertThat(state.offset).isEqualTo(80f) + assertThat(state.settledValue).isEqualTo(A) + assertThat(state.currentValue).isEqualTo(B) + + runBlocking(AutoTestFrameClock()) { + performFling(flingBehavior, state, velocityThreshold - 1f) + } + + assertThat(state.offset).isEqualTo(0f) + assertThat(state.settledValue).isEqualTo(A) + assertThat(state.currentValue).isEqualTo(A) + } + /** Test the [valueUnderTest] progressively for each delta from [from] to [to]. */ private suspend fun AnchoredDraggableState.testProgression( valueUnderTest: AnchoredDraggableState.() -> Any, diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableTestState.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableTestState.kt deleted file mode 100644 index fc5052e6a5124..0000000000000 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableTestState.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.foundation.anchoredDraggable - -import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.gestures.DraggableAnchors - -internal fun AnchoredDraggableTestState( - initialValue: T, - anchors: DraggableAnchors? = null, - confirmValueChange: (T) -> Boolean = { true } -): AnchoredDraggableState = - if (anchors != null) { - AnchoredDraggableState( - initialValue = initialValue, - confirmValueChange = confirmValueChange, - anchors = anchors - ) - } else { - AnchoredDraggableState( - initialValue = initialValue, - confirmValueChange = confirmValueChange, - ) - } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt index 52d451492642b..5719d94391104 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt @@ -161,6 +161,7 @@ internal object ContextMenuItemLabels { internal const val COPY = "Copy" internal const val PASTE = "Paste" internal const val SELECT_ALL = "Select all" + internal const val AUTOFILL = "Autofill" } internal fun ComposeTestRule.contextMenuItemInteraction( @@ -183,6 +184,7 @@ internal fun ComposeTestRule.assertContextMenuItems( copyState: ContextMenuItemState, pasteState: ContextMenuItemState, selectAllState: ContextMenuItemState, + autofillState: ContextMenuItemState, ) { val contextMenuInteraction = onNode(isPopup()) contextMenuInteraction.assertExists("Context Menu should exist.") @@ -191,6 +193,7 @@ internal fun ComposeTestRule.assertContextMenuItems( assertContextMenuItem(label = ContextMenuItemLabels.COPY, state = copyState) assertContextMenuItem(label = ContextMenuItemLabels.PASTE, state = pasteState) assertContextMenuItem(label = ContextMenuItemLabels.SELECT_ALL, state = selectAllState) + assertContextMenuItem(label = ContextMenuItemLabels.AUTOFILL, state = autofillState) } private fun ComposeTestRule.assertContextMenuItem(label: String, state: ContextMenuItemState) { diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt index 7e7cff4ea9811..ea9887d472842 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -238,6 +239,7 @@ private fun LazyColumn( reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, beyondBoundsItemCount = beyondBoundsItemCount, + overscrollEffect = rememberOverscrollEffect(), content = content ) } @@ -267,6 +269,7 @@ private fun LazyRow( reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, beyondBoundsItemCount = beyondBoundsItemCount, + overscrollEffect = rememberOverscrollEffect(), content = content ) } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt index 3e089e9f923c3..d3e0fb204d990 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt @@ -19,7 +19,7 @@ package androidx.compose.foundation.pager import android.view.View import androidx.compose.foundation.BaseLazyLayoutTestWithOrientation import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.Orientation @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.layout.PrefetchScheduler +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -125,6 +126,7 @@ open class BasePagerTest(private val config: ParamConfig) : beyondViewportPageCount: Int = config.beyondViewportPageCount, pageSize: () -> PageSize = { PageSize.Fill }, userScrollEnabled: Boolean = true, + overscrollEffect: @Composable () -> OverscrollEffect? = { rememberOverscrollEffect() }, snappingPage: PagerSnapDistance = PagerSnapDistance.atMost(1), nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {}, additionalContent: @Composable () -> Unit = {}, @@ -157,7 +159,6 @@ open class BasePagerTest(private val config: ParamConfig) : focusManager = LocalFocusManager.current CompositionLocalProvider( LocalLayoutDirection provides config.layoutDirection, - LocalOverscrollConfiguration provides null ) { val resolvedFlingBehavior = flingBehavior @@ -178,6 +179,7 @@ open class BasePagerTest(private val config: ParamConfig) : }, pageSize = pageSize(), userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect(), reverseLayout = reverseLayout, flingBehavior = resolvedFlingBehavior, pageSpacing = pageSpacing, @@ -304,6 +306,7 @@ open class BasePagerTest(private val config: ParamConfig) : state: PagerState = rememberPagerState(pageCount = { DefaultPageCount }), modifier: Modifier = Modifier, userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), reverseLayout: Boolean = false, contentPadding: PaddingValues = PaddingValues(0.dp), beyondViewportPageCount: Int = 0, @@ -319,6 +322,7 @@ open class BasePagerTest(private val config: ParamConfig) : state = state, modifier = modifier, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, reverseLayout = reverseLayout, contentPadding = contentPadding, beyondViewportPageCount = beyondViewportPageCount, @@ -334,6 +338,7 @@ open class BasePagerTest(private val config: ParamConfig) : state = state, modifier = modifier, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, reverseLayout = reverseLayout, contentPadding = contentPadding, beyondViewportPageCount = beyondViewportPageCount, diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt index 1f23d0ae8b52a..acabc470b3e2b 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyList import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll @@ -81,6 +82,7 @@ class PagerNestedScrollContentTest(config: ParamConfig) : BasePagerTest(config = reverseLayout = false, state = rememberLazyListState(), userScrollEnabled = true, + overscrollEffect = rememberOverscrollEffect(), verticalArrangement = Arrangement.Top, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top, @@ -126,6 +128,7 @@ class PagerNestedScrollContentTest(config: ParamConfig) : BasePagerTest(config = flingBehavior = flingInspector, state = rememberLazyListState(initialFirstVisibleItemIndex = 8), userScrollEnabled = true, + overscrollEffect = rememberOverscrollEffect(), verticalArrangement = Arrangement.Top, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top, @@ -188,6 +191,7 @@ class PagerNestedScrollContentTest(config: ParamConfig) : BasePagerTest(config = reverseLayout = false, state = rememberLazyListState(), userScrollEnabled = true, + overscrollEffect = rememberOverscrollEffect(), verticalArrangement = Arrangement.Top, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top, @@ -231,6 +235,7 @@ class PagerNestedScrollContentTest(config: ParamConfig) : BasePagerTest(config = reverseLayout = false, state = lazyListState, userScrollEnabled = true, + overscrollEffect = rememberOverscrollEffect(), verticalArrangement = Arrangement.Top, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top, @@ -283,6 +288,7 @@ class PagerNestedScrollContentTest(config: ParamConfig) : BasePagerTest(config = reverseLayout = false, state = lazyListState, userScrollEnabled = true, + overscrollEffect = rememberOverscrollEffect(), verticalArrangement = Arrangement.Top, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top, @@ -343,6 +349,7 @@ class PagerNestedScrollContentTest(config: ParamConfig) : BasePagerTest(config = reverseLayout = false, state = lazyListState, userScrollEnabled = true, + overscrollEffect = rememberOverscrollEffect(), verticalArrangement = Arrangement.Top, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top, diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerSwipeEdgeTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerSwipeEdgeTest.kt index 11e451d3702fa..c655c4912cdf1 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerSwipeEdgeTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerSwipeEdgeTest.kt @@ -19,6 +19,8 @@ package androidx.compose.foundation.pager import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.snapping.MinFlingVelocityDp import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertIsDisplayed @@ -71,6 +73,35 @@ class PagerSwipeEdgeTest(val config: ParamConfig) : BasePagerTest(config) { @Test fun scrollForwardAtTheLastPage_withSpacing_shouldNotMovePage() { + val collectedFractions = mutableListOf() + createPager( + modifier = Modifier.fillMaxSize(), + initialPage = DefaultPageCount - 1, + pageSpacing = 8.dp, + additionalContent = { + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPageOffsetFraction } + .collect { collectedFractions.add(it) } + } + } + ) + + val delta = pageSize * 0.4f * scrollForwardSign + val offsetDelta = if (vertical) Offset(0f, delta) else Offset(delta, 0f) + + onPager().performTouchInput { + down(center) + moveBy(offsetDelta) + } + + rule.runOnIdle { + assertTrue { collectedFractions.size == 1 } + assertTrue { collectedFractions[0] == 0.0f } + } + } + + @Test + fun scrollForwardAtTheLastPage_withSpacing_pageSettlesCorrectly() { createPager( modifier = Modifier.fillMaxSize(), diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt index 38c47b9783029..23b202db806ae 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt @@ -17,6 +17,7 @@ package androidx.compose.foundation.pager import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.snapping.SnapPosition @@ -30,7 +31,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.isNotDisplayed @@ -38,6 +44,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.test.filters.LargeTest import com.google.common.truth.Truth.assertThat @@ -492,7 +499,60 @@ class PagerTest(val config: ParamConfig) : BasePagerTest(config) { rule.runOnIdle { assertThat(firstItemOffset).isEqualTo(-1) } } + @Test + fun customOverscroll() { + val overscroll = TestOverscrollEffect() + createPager(modifier = Modifier.fillMaxSize(), overscrollEffect = { overscroll }) + + // The overscroll modifier should be added / drawn + rule.runOnIdle { assertThat(overscroll.drawCalled).isTrue() } + + onPager().performTouchInput { swipeWithVelocityAcrossMainAxis(1000f) } + + rule.runOnIdle { + // The swipe will result in multiple scroll deltas + assertThat(overscroll.applyToScrollCalledCount).isGreaterThan(1) + assertThat(overscroll.applyToFlingCalledCount).isEqualTo(1) + } + } + companion object { @JvmStatic @Parameterized.Parameters(name = "{0}") fun params() = AllOrientationsParams } + + private class TestOverscrollEffect : OverscrollEffect { + var applyToScrollCalledCount: Int = 0 + private set + + var applyToFlingCalledCount: Int = 0 + private set + + var drawCalled: Boolean = false + + override fun applyToScroll( + delta: Offset, + source: NestedScrollSource, + performScroll: (Offset) -> Offset + ): Offset { + applyToScrollCalledCount++ + return performScroll(delta) + } + + override suspend fun applyToFling( + velocity: Velocity, + performFling: suspend (Velocity) -> Velocity + ) { + applyToFlingCalledCount++ + performFling(velocity) + } + + override val isInProgress: Boolean = false + override val node: DelegatableNode = + object : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + drawContent() + drawCalled = true + } + } + } } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/SingleParamBasePagerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/SingleParamBasePagerTest.kt index c41ddb9f294a7..e356542b877ea 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/SingleParamBasePagerTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/SingleParamBasePagerTest.kt @@ -17,7 +17,7 @@ package androidx.compose.foundation.pager import android.view.View -import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.Orientation @@ -112,7 +112,7 @@ open class SingleParamBasePagerTest { CompositionLocalProvider( LocalLayoutDirection provides layoutDirection, - LocalOverscrollConfiguration provides null + LocalOverscrollFactory provides null ) { val resolvedFlingBehavior = flingBehavior diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt index 2acfad98cd67b..c1fbf653f31de 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt @@ -94,6 +94,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +@OptIn(ExperimentalTestApi::class) @MediumTest @RunWith(AndroidJUnit4::class) class ToggleableTest { @@ -803,6 +804,40 @@ class ToggleableTest { rule.runOnIdle { assertThat(toggled).isTrue() } } + @Test + @OptIn(ExperimentalTestApi::class) + fun toggleableTest_clickWithSpaceKey() { + val focusRequester = FocusRequester() + lateinit var inputModeManager: InputModeManager + var toggled by mutableStateOf(false) + rule.setContent { + inputModeManager = LocalInputModeManager.current + BasicText( + "ToggleableText", + modifier = + Modifier.testTag("toggleable").focusRequester(focusRequester).toggleable( + value = toggled + ) { + toggled = it + } + ) + } + + rule.runOnIdle { + inputModeManager.requestInputMode(Keyboard) + focusRequester.requestFocus() + } + + val toggleableNode = rule.onNodeWithTag("toggleable") + rule.runOnIdle { assertThat(toggled).isFalse() } + + toggleableNode.performKeyInput { keyDown(Key.Spacebar) } + rule.runOnIdle { assertThat(toggled).isFalse() } + + toggleableNode.performKeyInput { keyUp(Key.Spacebar) } + rule.runOnIdle { assertThat(toggled).isTrue() } + } + @Test @OptIn(ExperimentalTestApi::class) fun toggleableTest_clickWithNumPadEnterKey() { diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextLinkTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextLinkTest.kt index 064cb7669831d..c9ec255b24951 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextLinkTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextLinkTest.kt @@ -926,6 +926,19 @@ class BasicTextLinkTest { } } + @Test + fun link_withZeroLengthAnnotation_doesNotCrash() { + rule.setContent { + BasicText( + text = + buildAnnotatedString { + append("a") + addLink(url = Url("url"), start = 0, end = 0) + } + ) + } + } + @Composable private fun TextWithLinks() = with(rule.density) { diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingBoundsTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingBoundsTest.kt index f5b506013715a..0dcf7fe75b56a 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingBoundsTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingBoundsTest.kt @@ -19,10 +19,12 @@ package androidx.compose.foundation.text import android.view.inputmethod.CursorAnchorInfo import android.view.inputmethod.ExtractedText import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.setFocusableContent +import androidx.compose.foundation.text.handwriting.HandwritingBoundsVerticalOffset import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported import androidx.compose.foundation.text.input.InputMethodInterceptor import androidx.compose.foundation.text.input.internal.InputMethodManager @@ -38,8 +40,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.requestFocus import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.google.common.truth.Truth.assertThat @@ -120,6 +122,48 @@ class CoreTextFieldHandwritingBoundsTest { fakeImm.expectStylusHandwriting(true) } + @Test + fun coreTextField_stylusPointerInOverlappingArea_focusedEditorStartHandwriting() { + inputMethodManagerFactory = { fakeImm } + + val editorTag1 = "CoreTextField1" + val editorTag2 = "CoreTextField2" + val spacerTag = "Spacer" + + setContent { + Column(Modifier.safeContentPadding()) { + EditLine(Modifier.testTag(editorTag1)) + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(spacerTag) + ) + EditLine(Modifier.testTag(editorTag2)) + } + } + + rule.onNodeWithTag(editorTag2).requestFocus() + rule.waitForIdle() + + // Spacer's height equals to HandwritingBoundsVerticalPadding, both editor will receive the + // event. + rule.onNodeWithTag(spacerTag).performStylusHandwriting() + rule.waitForIdle() + + // Assert that focus didn't change, handwriting is started on the focused editor 2. + rule.onNodeWithTag(editorTag2).assertIsFocused() + fakeImm.expectStylusHandwriting(true) + + rule.onNodeWithTag(editorTag1).requestFocus() + rule.onNodeWithTag(spacerTag).performStylusHandwriting() + rule.waitForIdle() + + // Now handwriting is performed on the focused editor 1. + rule.onNodeWithTag(editorTag1).assertIsFocused() + fakeImm.expectStylusHandwriting(true) + } + @Composable fun EditLine(modifier: Modifier = Modifier) { var value by remember { mutableStateOf(TextFieldValue()) } @@ -131,7 +175,7 @@ class CoreTextFieldHandwritingBoundsTest { .fillMaxWidth() // make the size of TextFields equal to padding, so that touch bounds of editors // in the same column/row are overlapping. - .height(40.dp) + .height(HandwritingBoundsVerticalOffset) ) } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt index d917e80797c2f..fb916985ad697 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt @@ -1731,6 +1731,17 @@ class CoreTextFieldHandwritingGestureTest { return object : TextToolbar { private var _status: TextToolbarStatus = TextToolbarStatus.Hidden + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)? + ) { + _status = TextToolbarStatus.Shown + } + override fun showMenu( rect: Rect, onCopyRequested: (() -> Unit)?, diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt index cd855cb5c6539..577ca27e41c46 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt @@ -16,8 +16,12 @@ package androidx.compose.foundation.text +import android.os.Build import android.view.KeyEvent import android.view.MotionEvent +import android.view.PointerIcon +import android.view.View +import androidx.annotation.RequiresApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewRootForTest @@ -33,6 +37,7 @@ import androidx.compose.ui.unit.center import androidx.compose.ui.unit.toOffset import androidx.core.view.InputDeviceCompat import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat import kotlin.math.roundToInt // We don't have StylusInjectionScope at the moment. This is a simplified implementation for @@ -99,53 +104,44 @@ internal class HandwritingTestStylusInjectScope(semanticsNode: SemanticsNode) : sendTouchEvent(MotionEvent.ACTION_CANCEL) } - private fun sendTouchEvent(action: Int) { - val motionEvent = - MotionEvent.obtain( - /* downTime = */ downTime, - /* eventTime = */ currentTime, - /* action = */ action, - /* pointerCount = */ 1, - /* pointerProperties = */ arrayOf( - MotionEvent.PointerProperties().apply { - id = 0 - toolType = MotionEvent.TOOL_TYPE_STYLUS - } - ), - /* pointerCoords = */ arrayOf( - MotionEvent.PointerCoords().apply { - val startOffset = lastPosition - - // Allows for non-valid numbers/Offsets to be passed along to Compose to - // test if it handles them properly (versus breaking here and we not knowing - // if Compose properly handles these values). - x = - if (startOffset.isValid()) { - startOffset.x - } else { - Float.NaN - } - - y = - if (startOffset.isValid()) { - startOffset.y - } else { - Float.NaN - } - } - ), - /* metaState = */ 0, - /* buttonState = */ 0, - /* xPrecision = */ 1f, - /* yPrecision = */ 1f, - /* deviceId = */ 0, - /* edgeFlags = */ 0, - /* source = */ InputDeviceCompat.SOURCE_TOUCHSCREEN, - /* flags = */ 0 - ) + fun hoverEnter(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) { + advanceEventTime(delayMillis) + lastPosition = localToRoot(position) + sendTouchEvent(MotionEvent.ACTION_HOVER_ENTER) + } + + fun hoverMoveTo(position: Offset, delayMillis: Long = eventPeriodMillis) { + advanceEventTime(delayMillis) + lastPosition = localToRoot(position) + sendTouchEvent(MotionEvent.ACTION_HOVER_MOVE) + } + + fun hoverExit(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) { + advanceEventTime(delayMillis) + lastPosition = localToRoot(position) + sendTouchEvent(MotionEvent.ACTION_HOVER_EXIT) + } + private fun sendTouchEvent(action: Int) { + val startOffset = lastPosition + + // Allows for non-valid numbers/Offsets to be passed along to Compose to + // test if it handles them properly (versus breaking here and we not knowing + // if Compose properly handles these values). + val x = + if (startOffset.isValid()) { + startOffset.x + } else { + Float.NaN + } + val y = + if (startOffset.isValid()) { + startOffset.y + } else { + Float.NaN + } InstrumentationRegistry.getInstrumentation().runOnMainSync { - root.view.dispatchTouchEvent(motionEvent) + root.view.dispatchTouchEvent(obtainMotionEvent(downTime, currentTime, action, x, y)) } } } @@ -186,8 +182,8 @@ internal fun SemanticsNodeInteraction.performStylusLongPressAndDrag() { } } -private fun SemanticsNodeInteraction.performStylusInput( - block: TouchInjectionScope.() -> Unit +internal fun SemanticsNodeInteraction.performStylusInput( + block: HandwritingTestStylusInjectScope.() -> Unit ): SemanticsNodeInteraction { @OptIn(ExperimentalTestApi::class) invokeGlobalAssertions() tryPerformAccessibilityChecks() @@ -196,3 +192,44 @@ private fun SemanticsNodeInteraction.performStylusInput( block.invoke(stylusInjectionScope) return this } + +@RequiresApi(Build.VERSION_CODES.N) +internal fun assertNoStylusHoverIcon(view: View) { + val event = obtainMotionEvent(0L, 0L, MotionEvent.ACTION_HOVER_MOVE, 0f, 0f) + assertThat(view.onResolvePointerIcon(event, 0)).isNull() +} + +@RequiresApi(Build.VERSION_CODES.N) +internal fun assertStylusHandwritingHoverIcon(view: View) { + val event = obtainMotionEvent(0L, 0L, MotionEvent.ACTION_HOVER_MOVE, 0f, 0f) + assertThat(view.onResolvePointerIcon(event, 0)) + .isEqualTo(PointerIcon.getSystemIcon(view.context, PointerIcon.TYPE_HANDWRITING)) +} + +private fun obtainMotionEvent(downTime: Long, eventTime: Long, action: Int, x: Float, y: Float) = + MotionEvent.obtain( + downTime, + eventTime, + action, + /* pointerCount= */ 1, + /* pointerProperties= */ arrayOf( + MotionEvent.PointerProperties().apply { + id = 0 + toolType = MotionEvent.TOOL_TYPE_STYLUS + } + ), + /* pointerCoords= */ arrayOf( + MotionEvent.PointerCoords().apply { + this.x = x + this.y = y + } + ), + /* metaState= */ 0, + /* buttonState= */ 0, + /* xPrecision= */ 1f, + /* yPrecision= */ 1f, + /* deviceId= */ 0, + /* edgeFlags= */ 0, + /* source= */ InputDeviceCompat.SOURCE_STYLUS, + /* flags= */ 0 + ) diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicSecureTextFieldTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicSecureTextFieldTest.kt index a409c358cd898..426a0d37ef5e9 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicSecureTextFieldTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicSecureTextFieldTest.kt @@ -396,7 +396,7 @@ internal class BasicSecureTextFieldTest { var showMenuRequested = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, onCopyRequested, _, onCutRequested, _ -> + onShowMenu = { _, onCopyRequested, _, onCutRequested, _, _ -> showMenuRequested = true copyOptionAvailable = onCopyRequested != null cutOptionAvailable = onCutRequested != null diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldHandwritingBoundsTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldHandwritingBoundsTest.kt new file mode 100644 index 0000000000000..0a172fb08b211 --- /dev/null +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldHandwritingBoundsTest.kt @@ -0,0 +1,140 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text.input + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.handwriting.HandwritingBoundsVerticalOffset +import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported +import androidx.compose.foundation.text.performStylusHandwriting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.requestFocus +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +internal class BasicTextFieldHandwritingBoundsTest { + @get:Rule val rule = createComposeRule() + + @get:Rule val immRule = ComposeInputMethodManagerTestRule() + + private val inputMethodInterceptor = InputMethodInterceptor(rule) + + private val imm = FakeInputMethodManager() + + @Before + fun setup() { + // Test is only meaningful when stylus handwriting is supported. + assumeTrue(isStylusHandwritingSupported) + } + + @Test + fun basicTextField_stylusPointerInEditorBounds_focusAndStartHandwriting() { + immRule.setFactory { imm } + + val editorTag1 = "BasicTextField1" + val editorTag2 = "BasicTextField2" + + inputMethodInterceptor.setTextFieldTestContent { + Column(Modifier.safeContentPadding()) { + EditLine(Modifier.testTag(editorTag1)) + EditLine(Modifier.testTag(editorTag2)) + } + } + + rule.onNodeWithTag(editorTag1).performStylusHandwriting() + + rule.waitForIdle() + + rule.onNodeWithTag(editorTag1).assertIsFocused() + imm.expectCall("startStylusHandwriting") + } + + @Test + fun basicTextField_stylusPointerInOverlappingArea_focusedEditorStartHandwriting() { + immRule.setFactory { imm } + + val editorTag1 = "BasicTextField1" + val editorTag2 = "BasicTextField2" + val spacerTag = "Spacer" + + inputMethodInterceptor.setTextFieldTestContent { + Column(Modifier.safeContentPadding()) { + EditLine(Modifier.testTag(editorTag1)) + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(spacerTag) + ) + EditLine(Modifier.testTag(editorTag2)) + } + } + + rule.onNodeWithTag(editorTag2).requestFocus() + rule.waitForIdle() + + // Spacer's height equals to HandwritingBoundsVerticalPadding, both editor will receive the + // event. + rule.onNodeWithTag(spacerTag).performStylusHandwriting() + rule.waitForIdle() + + // Assert that focus didn't change, handwriting is started on the focused editor 2. + rule.onNodeWithTag(editorTag2).assertIsFocused() + imm.expectCall("startStylusHandwriting") + + rule.onNodeWithTag(editorTag1).requestFocus() + rule.onNodeWithTag(spacerTag).performStylusHandwriting() + rule.waitForIdle() + + // Now handwriting is performed on the focused editor 1. + rule.onNodeWithTag(editorTag1).assertIsFocused() + imm.expectCall("startStylusHandwriting") + } + + @Composable + fun EditLine(modifier: Modifier = Modifier) { + val state = remember { TextFieldState() } + BasicTextField( + state = state, + modifier = + modifier + .fillMaxWidth() + // make the size of TextFields equal to padding, so that touch bounds of editors + // in the same column/row are overlapping. + .height(HandwritingBoundsVerticalOffset) + ) + } +} diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt index 9acd55108c4d5..2ef8d96ed849f 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.testutils.expectError import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentDataType import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.SemanticsActions @@ -104,14 +105,16 @@ class BasicTextFieldSemanticsTest : FocusedWindowTest { .assert(hasSetTextAction()) .assert(hasImeAction(ImeAction.Default)) .assert(isNotFocused()) - .assert( - SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange.Zero) - ) + .assert(expectValue(SemanticsProperties.TextSelectionRange, TextRange.Zero)) .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText)) .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.PasteText)) .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Password)) .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetSelection)) .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult)) + // All text elements should be automatically be autofillable and of "Text" data type + .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentDataType)) + .assert(expectValue(SemanticsProperties.ContentDataType, ContentDataType.Text)) + .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.OnAutofillText)) val textLayoutResults = mutableListOf() rule.onNodeWithTag(Tag).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt index 99e074f52bd92..0ede4c60422e6 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt @@ -18,6 +18,8 @@ package androidx.compose.foundation.text.input import android.os.Build import android.text.InputType +import android.text.SpannableStringBuilder +import android.text.style.BackgroundColorSpan import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import androidx.compose.foundation.ExperimentalFoundationApi @@ -77,6 +79,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotFocused import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasPerformImeAction import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithTag @@ -107,10 +110,12 @@ import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.text.toSpanned import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.filters.SdkSuppress import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNotNull import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.drop import org.junit.Rule @@ -1362,7 +1367,7 @@ internal class BasicTextFieldTest { fun composingRegion_changesInvalidateLayout() { val state = TextFieldState("Hello, World") var textLayoutProvider: (() -> TextLayoutResult?)? by mutableStateOf(null) - state.editAsUser(inputTransformation = null) { setComposingRegion(0, 5) } + state.editAsUser(inputTransformation = null) { setComposition(0, 5) } inputMethodInterceptor.setTextFieldTestContent { BasicTextField( @@ -1560,6 +1565,40 @@ internal class BasicTextFieldTest { rule.runOnIdle { imm.expectNoMoreCalls() } } + @Test + fun outputTransformation_doesNotLoseComposingAnnotations() { + val textFieldState = TextFieldState() + var textLayoutProvider: (() -> TextLayoutResult?)? = null + inputMethodInterceptor.setTextFieldTestContent { + BasicTextField( + textFieldState, + onTextLayout = { textLayoutProvider = it }, + outputTransformation = { append(" world") } + ) + } + + rule.onNode(hasPerformImeAction()).requestFocus() + + val spanned = + SpannableStringBuilder() + .append("Hello", BackgroundColorSpan(android.graphics.Color.RED), 0) + .toSpanned() + + inputMethodInterceptor.withInputConnection { setComposingText(spanned, 1) } + + rule.runOnIdle { + val textLayoutResult = textLayoutProvider?.invoke() + assertNotNull(textLayoutResult) + val annotatedString = textLayoutResult.layoutInput.text + val spanStyles = annotatedString.spanStyles + assertThat(annotatedString.toString()).isEqualTo("Hello world") + assertThat(spanStyles.size).isEqualTo(1) + assertThat(spanStyles.first().start).isEqualTo(0) + assertThat(spanStyles.first().end).isEqualTo(5) + assertThat(spanStyles.first().item.background).isEqualTo(Color.Red) + } + } + private fun requestFocus(tag: String) = rule.onNodeWithTag(tag).requestFocus() private fun assertTextSelection(expected: TextRange) { diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingDetectorTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingDetectorTest.kt index 3a2502fada9de..c08ad2bc35f65 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingDetectorTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingDetectorTest.kt @@ -16,28 +16,34 @@ package androidx.compose.foundation.text.input +import android.os.Build +import android.view.View import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.text.assertNoStylusHoverIcon +import androidx.compose.foundation.text.assertStylusHandwritingHoverIcon +import androidx.compose.foundation.text.handwriting.HandwritingBoundsVerticalOffset import androidx.compose.foundation.text.handwriting.handwritingDetector import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported import androidx.compose.foundation.text.performStylusClick import androidx.compose.foundation.text.performStylusHandwriting +import androidx.compose.foundation.text.performStylusInput import androidx.compose.foundation.text.performStylusLongClick import androidx.compose.foundation.text.performStylusLongPressAndDrag import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress import com.google.common.truth.Truth.assertThat import org.junit.Assume import org.junit.Before -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -57,6 +63,8 @@ internal class HandwritingDetectorTest { private var callbackCount = 0 + private lateinit var ownerView: View + @Before fun setup() { // Test is only meaningful when stylus handwriting is supported. @@ -67,18 +75,30 @@ internal class HandwritingDetectorTest { callbackCount = 0 rule.setContent { + ownerView = LocalView.current + Column(Modifier.safeContentPadding()) { Spacer( modifier = Modifier.fillMaxWidth() - .height(40.dp) + .height(HandwritingBoundsVerticalOffset) .handwritingDetector { callbackCount++ } .testTag(detectorTag) ) // This spacer is within the extended handwriting bounds of the detector - Spacer(modifier = Modifier.fillMaxWidth().height(10.dp).testTag(insideSpacerTag)) + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(insideSpacerTag) + ) // This spacer is outside the extended handwriting bounds of the detector - Spacer(modifier = Modifier.fillMaxWidth().height(10.dp).testTag(outsideSpacerTag)) + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(outsideSpacerTag) + ) } } } @@ -90,10 +110,7 @@ internal class HandwritingDetectorTest { assertHandwritingDelegationPrepared() } - // Extended bounds is reverted due to b/346850837 will enable it when we support extended - // bounds for handwriting again. @Test - @Ignore fun detector_handwritingInExtendedBounds_preparesDelegation() { // This spacer is within the extended handwriting bounds of the detector rule.onNodeWithTag(insideSpacerTag).performStylusHandwriting() @@ -102,7 +119,6 @@ internal class HandwritingDetectorTest { } @Test - @Ignore fun detector_handwritingOutsideExtendedBounds_notPreparesDelegation() { // This spacer is outside the extended handwriting bounds of the detector rule.onNodeWithTag(outsideSpacerTag).performStylusHandwriting() @@ -131,6 +147,25 @@ internal class HandwritingDetectorTest { assertHandwritingDelegationNotPrepared() } + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU) + @Test + fun detector_hover_showsHandwritingIcon() { + // No stylus icon shown before hover starts + rule.runOnIdle { assertNoStylusHoverIcon(ownerView) } + + // This spacer is within the extended handwriting bounds of the detector, so icon is shown + rule.onNodeWithTag(insideSpacerTag).performStylusInput { hoverEnter(center) } + rule.runOnIdle { assertStylusHandwritingHoverIcon(ownerView) } + + // This is within the detector, so icon is shown + rule.onNodeWithTag(detectorTag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertStylusHandwritingHoverIcon(ownerView) } + + // This spacer is outside the extended handwriting bounds of the detector, so no icon shown + rule.onNodeWithTag(outsideSpacerTag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertNoStylusHoverIcon(ownerView) } + } + private fun assertHandwritingDelegationPrepared() { rule.runOnIdle { assertThat(callbackCount).isEqualTo(1) diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingHoverIconTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingHoverIconTest.kt new file mode 100644 index 0000000000000..cd66877c22147 --- /dev/null +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/HandwritingHoverIconTest.kt @@ -0,0 +1,187 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text.input + +import android.os.Build +import android.view.View +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.CoreTextField +import androidx.compose.foundation.text.assertNoStylusHoverIcon +import androidx.compose.foundation.text.assertStylusHandwritingHoverIcon +import androidx.compose.foundation.text.handwriting.HandwritingBoundsVerticalOffset +import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported +import androidx.compose.foundation.text.performStylusInput +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress +import org.junit.Assume +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@MediumTest +@RunWith(AndroidJUnit4::class) +internal class HandwritingHoverIconTest { + @get:Rule val rule = createComposeRule() + + private lateinit var ownerView: View + + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU) + @Test + fun stylusHandwritingHoverIcon() { + // Test is only meaningful when stylus handwriting is supported. + Assume.assumeTrue(isStylusHandwritingSupported) + + val basicTextFieldTag = "basicTextField" + val coreTextFieldTag = "coreTextField" + val coreTextFieldUnsupportedTag = "coreTextFieldUnsupported" + val outsideBoundsSpacer1Tag = "spacer1" + val withinBoundsSpacer2Tag = "spacer2" + val withinBoundsSpacer3Tag = "spacer3" + val withinBoundsSpacer4Tag = "spacer4" + val outsideBoundsSpacer5Tag = "spacer5" + + rule.setContent { + ownerView = LocalView.current + + val basicTextFieldState = remember { TextFieldState() } + var coreTextFieldValue by remember { mutableStateOf(TextFieldValue()) } + var coreTextFieldUnsupportedValue by remember { mutableStateOf(TextFieldValue()) } + + Column(Modifier.safeContentPadding()) { + // This spacer is outside the extended handwriting bounds of the text fields + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(outsideBoundsSpacer1Tag) + ) + // This spacer is within the extended handwriting bounds of basicTextField + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(withinBoundsSpacer2Tag) + ) + // This text field supports handwriting + BasicTextField( + state = basicTextFieldState, + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(basicTextFieldTag) + ) + // This spacer is within the extended handwriting bounds of both text fields + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(withinBoundsSpacer3Tag) + ) + // This text field supports handwriting + CoreTextField( + value = coreTextFieldValue, + onValueChange = { coreTextFieldValue = it }, + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(coreTextFieldTag) + ) + // This spacer is within the extended handwriting bounds of coreTextField + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(withinBoundsSpacer4Tag) + ) + // This spacer is outside the extended handwriting bounds of the text fields + Spacer( + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(outsideBoundsSpacer5Tag) + ) + // Password text field does not support handwriting + CoreTextField( + value = coreTextFieldUnsupportedValue, + onValueChange = { coreTextFieldUnsupportedValue = it }, + imeOptions = ImeOptions(keyboardType = KeyboardType.Password), + modifier = + Modifier.fillMaxWidth() + .height(HandwritingBoundsVerticalOffset) + .testTag(coreTextFieldUnsupportedTag) + ) + } + } + + // No stylus icon shown before hover starts + rule.runOnIdle { assertNoStylusHoverIcon(ownerView) } + + // This spacer is outside the extended handwriting bounds of the text fields, no icon shown + rule.onNodeWithTag(outsideBoundsSpacer1Tag).performStylusInput { hoverEnter(center) } + rule.runOnIdle { assertNoStylusHoverIcon(ownerView) } + + // This spacer is within the extended handwriting bounds of basicTextField, so icon is shown + rule.onNodeWithTag(withinBoundsSpacer2Tag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertStylusHandwritingHoverIcon(ownerView) } + + // This is within basicTextField, so icon is shown + rule.onNodeWithTag(basicTextFieldTag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertStylusHandwritingHoverIcon(ownerView) } + + // This spacer is within the extended handwriting bounds of basicTextField, so icon is shown + rule.onNodeWithTag(withinBoundsSpacer4Tag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertStylusHandwritingHoverIcon(ownerView) } + + // This spacer is outside the extended handwriting bounds of the text fields, no icon shown + rule.onNodeWithTag(outsideBoundsSpacer5Tag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertNoStylusHoverIcon(ownerView) } + + // This is within coreTextField, so icon is shown + rule.onNodeWithTag(coreTextFieldTag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertStylusHandwritingHoverIcon(ownerView) } + + // This is within coreTextFieldUnsupported, so no icon shown + rule.onNodeWithTag(coreTextFieldUnsupportedTag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertNoStylusHoverIcon(ownerView) } + + // This spacer is within the extended handwriting bounds of both text fields, icon is shown + rule.onNodeWithTag(withinBoundsSpacer3Tag).performStylusInput { hoverMoveTo(center) } + rule.runOnIdle { assertStylusHandwritingHoverIcon(ownerView) } + + // Hover exit, so no icon shown + rule.onNodeWithTag(withinBoundsSpacer3Tag).performStylusInput { hoverExit(center) } + rule.runOnIdle { assertNoStylusHoverIcon(ownerView) } + } +} diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt index 72989bad573ab..03d49454237f2 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt @@ -23,11 +23,14 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.click import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.test.withKeyDown import androidx.compose.ui.text.TextRange import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -264,6 +267,309 @@ class TextFieldOutputTransformationHardwareKeysIntegrationTest { assertCursor(0) } + @Test + fun insert_cursorMovement_leftToRight_byWord_stateOffsets() { + val text = TextFieldState("ab", initialSelection = TextRange(0)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(2, "efg hij") // "abefg hij" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertThat(text.selection).isEqualTo(TextRange(0)) + moveRightByWord() + assertThat(text.selection).isEqualTo(TextRange(2)) + moveRightByWord() + assertThat(text.selection).isEqualTo(TextRange(2)) + moveLeftByWord() + assertThat(text.selection).isEqualTo(TextRange(2)) + } + + @Test + fun insert_cursorMovement_leftToRight_byWord_semanticsOffsets() { + val text = TextFieldState("ab", initialSelection = TextRange(0)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(2, "efg hij") // "abefg hij" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertCursor(0) + moveRightByWord() + assertCursor(9) + moveRightByWord() + assertCursor(9) + moveLeftByWord() + assertCursor(2) + } + + @Test + fun insert_cursorMovement_rightToLeft_byWord_stateOffsets() { + val text = TextFieldState("ab", initialSelection = TextRange(2)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(0, "hij efg") // "hij efgab" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertThat(text.selection).isEqualTo(TextRange(2)) + moveLeftByWord() + assertThat(text.selection).isEqualTo(TextRange(0)) + moveLeftByWord() + assertThat(text.selection).isEqualTo(TextRange(0)) + moveRightByWord() + assertThat(text.selection).isEqualTo(TextRange(0)) + } + + @Test + fun insert_cursorMovement_rightToLeft_byWord_semanticsOffsets() { + val text = TextFieldState("ab", initialSelection = TextRange(2)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(0, "hij efg") // "hij efgab" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertThat(text.selection).isEqualTo(TextRange(2)) + moveLeftByWord() + assertThat(text.selection).isEqualTo(TextRange(0)) + moveLeftByWord() + assertThat(text.selection).isEqualTo(TextRange(0)) + moveRightByWord() + assertThat(text.selection).isEqualTo(TextRange(0)) + } + + @Test + fun insert_cursorMovement_leftToRight_byLine_stateOffsets() { + val text = TextFieldState("abc def", initialSelection = TextRange(0)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(4, "xyz\nxyz ") // "abc xyz\nxyz def" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertThat(text.selection).isEqualTo(TextRange(0)) + moveRightByLine() + assertThat(text.selection).isEqualTo(TextRange(4)) + moveRightByLine() + assertThat(text.selection).isEqualTo(TextRange(7)) + } + + @Test + fun insert_cursorMovement_leftToRight_byLine_semanticsOffsets() { + val text = TextFieldState("abc def", initialSelection = TextRange(0)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(4, "xyz\nxyz ") // "abc xyz\nxyz def" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertCursor(0) + moveRightByLine() + assertCursor(12) + moveRightByLine() + assertCursor(15) + } + + @Test + fun insert_cursorMovement_rightToLeft_byLine_stateOffsets() { + val text = TextFieldState("abc def", initialSelection = TextRange(7)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(4, "xyz\nxyz ") // "abc xyz\nxyz def" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertThat(text.selection).isEqualTo(TextRange(7)) + moveLeftByLine() + assertThat(text.selection).isEqualTo(TextRange(4)) + moveLeftByLine() + assertThat(text.selection).isEqualTo(TextRange(0)) + } + + @Test + fun insert_cursorMovement_rightToLeft_byLine_semanticsOffsets() { + val text = TextFieldState("abc def", initialSelection = TextRange(7)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(4, "xyz\nxyz ") // "abc xyz\nxyz def" + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + assertCursor(15) + moveLeftByLine() + assertCursor(4) + moveLeftByLine() + assertCursor(0) + } + + @Test + fun mixed_cursorMovement_leftToRight_allOffsets() { + val text = TextFieldState("abc def ghi", initialSelection = TextRange(0)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(0, ">") // >abc def ghi + replace(1, 4, "wedge") // >wedge def ghi + delete(7, 10) // >wedge ghi + insert(8, "insertion") // >wedge insertionghi + insert(length, "<") // >wedge insertionghi< + } + ) + } + rule.onNodeWithTag(Tag).requestFocus() + + val stateOffsets = mutableListOf() + val semanticsOffsets = mutableListOf() + + for (i in 0 until 10) { + stateOffsets += text.selection + semanticsOffsets += + rule + .onNodeWithTag(Tag) + .fetchSemanticsNode() + .config[SemanticsProperties.TextSelectionRange] + pressKey(Key.DirectionRight) + } + + assertThat(stateOffsets) + .isEqualTo( + listOf( + TextRange(0), + TextRange(0), + TextRange(3), + TextRange(4, 7), + TextRange(8), + TextRange(8), + TextRange(9), + TextRange(10), + TextRange(11), + TextRange(11) + ) + ) + assertThat(semanticsOffsets) + .isEqualTo( + listOf( + TextRange(0), + TextRange(1), + TextRange(6), + TextRange(7), + TextRange(8), + TextRange(17), + TextRange(18), + TextRange(19), + TextRange(20), + TextRange(21) + ) + ) + } + + @Test + fun mixed_cursorMovement_rightToLeft_allOffsets() { + val text = TextFieldState("abc def ghi", initialSelection = TextRange(11)) + inputMethodInterceptor.setContent { + BasicTextField( + state = text, + modifier = Modifier.testTag(Tag), + outputTransformation = { + insert(0, ">") // >abc def ghi + replace(1, 4, "wedge") // >wedge def ghi + delete(7, 10) // >wedge ghi + insert(8, "insertion") // >wedge insertionghi + insert(length, "<") // >wedge insertionghi< + } + ) + } + // guarantees selection wedge affinity. + rule.onNodeWithTag(Tag).performTouchInput { click(centerRight) } + + val stateOffsets = mutableListOf() + val semanticsOffsets = mutableListOf() + + for (i in 0 until 10) { + stateOffsets += text.selection + semanticsOffsets += + rule + .onNodeWithTag(Tag) + .fetchSemanticsNode() + .config[SemanticsProperties.TextSelectionRange] + pressKey(Key.DirectionLeft) + } + + assertThat(stateOffsets) + .isEqualTo( + listOf( + TextRange(11), + TextRange(11), + TextRange(10), + TextRange(9), + TextRange(8), + TextRange(8), + TextRange(4, 7), + TextRange(3), + TextRange(0), + TextRange(0) + ) + ) + assertThat(semanticsOffsets) + .isEqualTo( + listOf( + TextRange(21), + TextRange(20), + TextRange(19), + TextRange(18), + TextRange(17), + TextRange(8), + TextRange(7), + TextRange(6), + TextRange(1), + TextRange(0) + ) + ) + } + private fun assertVisualText(text: String) { assertThat(rule.onNodeWithTag(Tag).fetchTextLayoutResult().layoutInput.text.text) .isEqualTo(text) @@ -279,4 +585,26 @@ class TextFieldOutputTransformationHardwareKeysIntegrationTest { private fun pressKey(key: Key) { rule.onNodeWithTag(Tag).performKeyInput { pressKey(key) } } + + private fun moveLeftByWord() = moveByWord(false) + + private fun moveRightByWord() = moveByWord(true) + + private fun moveByWord(direction: Boolean) { + rule.onNodeWithTag(Tag).performKeyInput { + withKeyDown(Key.CtrlLeft) { + if (direction) pressKey(Key.DirectionRight) else pressKey(Key.DirectionLeft) + } + } + } + + private fun moveLeftByLine() = moveByLine(false) + + private fun moveRightByLine() = moveByLine(true) + + private fun moveByLine(direction: Boolean) { + rule.onNodeWithTag(Tag).performKeyInput { + if (direction) pressKey(Key.MoveEnd) else pressKey(Key.MoveHome) + } + } } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldReceiveContentTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldReceiveContentTest.kt index 034f2e7dee433..b396dc24b19a0 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldReceiveContentTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldReceiveContentTest.kt @@ -400,7 +400,7 @@ class TextFieldReceiveContentTest { var pasteOption: (() -> Unit)? = null val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, onPasteRequested, _, _ -> pasteOption = onPasteRequested }, + onShowMenu = { _, _, onPasteRequested, _, _, _ -> pasteOption = onPasteRequested }, onHideMenu = {} ) lateinit var transferableContent: TransferableContent diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSessionTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSessionTest.kt index 83473b386fa68..df846d69db30f 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSessionTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSessionTest.kt @@ -242,6 +242,7 @@ class AndroidTextInputSessionTest { composeImm = composeImm, receiveContentConfiguration = receiveContentConfiguration, onImeAction = onImeAction, + updateSelectionState = null, stylusHandwritingTrigger = null, viewConfiguration = null ) diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/BackspaceCommandTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/BackspaceCommandTest.kt deleted file mode 100644 index a9cf3a645badf..0000000000000 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/BackspaceCommandTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.foundation.text.input.internal - -import androidx.compose.foundation.text.input.TextFieldBuffer -import androidx.compose.foundation.text.input.TextFieldCharSequence -import androidx.compose.ui.text.TextRange -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SdkSuppress -import androidx.test.filters.SmallTest -import com.google.common.truth.Truth -import org.junit.Test -import org.junit.runner.RunWith - -@SmallTest -@RunWith(AndroidJUnit4::class) -class BackspaceCommandTest { - - // Test sample surrogate pair characters. - private val SP1 = "\uD83D\uDE00" // U+1F600: GRINNING FACE - private val SP2 = "\uD83D\uDE01" // U+1F601: GRINNING FACE WITH SMILING EYES - private val SP3 = "\uD83D\uDE02" // U+1F602: FACE WITH TEARS OF JOY - private val SP4 = "\uD83D\uDE03" // U+1F603: SMILING FACE WITH OPEN MOUTH - private val SP5 = "\uD83D\uDE04" // U+1F604: SMILING FACE WITH OPEN MOUTH AND SMILING EYES - - // Family ZWJ Emoji: U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466 - private val ZWJ_EMOJI = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66" - - @Test - fun test_delete() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo("BCDE") - Truth.assertThat(eb.selection.start).isEqualTo(0) - Truth.assertThat(eb.selection.end).isEqualTo(0) - Truth.assertThat(eb.hasComposition()).isFalse() - } - - @Test - fun test_delete_from_offset0() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo("ABCDE") - Truth.assertThat(eb.selection.start).isEqualTo(0) - Truth.assertThat(eb.selection.end).isEqualTo(0) - Truth.assertThat(eb.hasComposition()).isFalse() - } - - @Test - fun test_delete_with_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(2, 3)) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo("ABDE") - Truth.assertThat(eb.selection.start).isEqualTo(2) - Truth.assertThat(eb.selection.end).isEqualTo(2) - Truth.assertThat(eb.hasComposition()).isFalse() - } - - @Test - fun test_delete_with_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) - eb.setComposition(2, 3) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo("ABDE") - Truth.assertThat(eb.selection.start).isEqualTo(1) - Truth.assertThat(eb.selection.end).isEqualTo(1) - Truth.assertThat(eb.hasComposition()).isFalse() - } - - @Test - fun test_delete_surrogate_pair() { - val eb = TextFieldBuffer("$SP1$SP2$SP3$SP4$SP5", TextRange(2)) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo("$SP2$SP3$SP4$SP5") - Truth.assertThat(eb.selection.start).isEqualTo(0) - Truth.assertThat(eb.selection.end).isEqualTo(0) - Truth.assertThat(eb.hasComposition()).isFalse() - } - - @Test - fun test_delete_with_selection_surrogate_pair() { - val eb = TextFieldBuffer("$SP1$SP2$SP3$SP4$SP5", TextRange(4, 6)) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo("$SP1$SP2$SP4$SP5") - Truth.assertThat(eb.selection.start).isEqualTo(4) - Truth.assertThat(eb.selection.end).isEqualTo(4) - Truth.assertThat(eb.hasComposition()).isFalse() - } - - @Test - fun test_delete_with_composition_surrogate_pair() { - val eb = TextFieldBuffer("$SP1$SP2$SP3$SP4$SP5", TextRange(2)) - eb.setComposition(4, 6) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo("$SP1$SP2$SP4$SP5") - Truth.assertThat(eb.selection.start).isEqualTo(2) - Truth.assertThat(eb.selection.end).isEqualTo(2) - Truth.assertThat(eb.hasComposition()).isFalse() - } - - @Test - @SdkSuppress(minSdkVersion = 26) - fun test_delete_with_composition_zwj_emoji() { - val eb = TextFieldBuffer("$ZWJ_EMOJI$ZWJ_EMOJI", TextRange(ZWJ_EMOJI.length)) - - eb.backspace() - - Truth.assertThat(eb.toString()).isEqualTo(ZWJ_EMOJI) - Truth.assertThat(eb.selection.start).isEqualTo(0) - Truth.assertThat(eb.selection.end).isEqualTo(0) - Truth.assertThat(eb.hasComposition()).isFalse() - } -} - -internal fun TextFieldBuffer( - initialValue: String = "", - initialSelection: TextRange = TextRange.Zero -) = TextFieldBuffer(TextFieldCharSequence(initialValue, initialSelection)) diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnectionTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnectionTest.kt index 9c99e9a011705..559ccc15835f2 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnectionTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnectionTest.kt @@ -41,6 +41,8 @@ import androidx.compose.foundation.content.TransferableContent import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.insert +import androidx.compose.runtime.collection.mutableVectorOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.firstUriOrNull @@ -84,8 +86,36 @@ class StatelessInputConnectionTest { this@StatelessInputConnectionTest.onImeAction?.invoke(imeAction) } - override fun requestEdit(block: TextFieldBuffer.() -> Unit) { - onRequestEdit?.invoke(block) + override fun mapFromTransformed(range: TextRange): TextRange { + mapFromTransformedCalled = range + return state.mapFromTransformed(range) + } + + override fun mapToTransformed(range: TextRange): TextRange { + mapToTransformedCalled = range + return state.mapToTransformed(range) + } + + override fun beginBatchEdit(): Boolean { + beginBatchEditCalls++ + batchDepth++ + return true + } + + override fun edit(block: TextFieldBuffer.() -> Unit) { + beginBatchEdit() + edits.add(block) + endBatchEdit() + } + + override fun endBatchEdit(): Boolean { + endBatchEditCalls++ + batchDepth-- + if (batchDepth == 0 && edits.isNotEmpty()) { + onRequestEdit?.invoke { edits.forEach { it.invoke(this) } } + edits.clear() + } + return batchDepth > 0 } override fun sendKeyEvent(keyEvent: KeyEvent) { @@ -112,11 +142,12 @@ class StatelessInputConnectionTest { } } - private var state: TextFieldState = TextFieldState() + private var edits = mutableVectorOf Unit>() + private var state: TransformedTextFieldState = TransformedTextFieldState(TextFieldState()) private var value: TextFieldCharSequence = TextFieldCharSequence() set(value) { field = value - state = TextFieldState(value.toString(), value.selection) + state = TransformedTextFieldState(TextFieldState(value.toString(), value.selection)) } private var onRequestEdit: ((TextFieldBuffer.() -> Unit) -> Unit)? = null @@ -124,6 +155,13 @@ class StatelessInputConnectionTest { private var onImeAction: ((ImeAction) -> Unit)? = null private var onCommitContent: ((TransferableContent) -> Boolean)? = null + private var beginBatchEditCalls = 0 + private var endBatchEditCalls = 0 + private var mapFromTransformedCalled: TextRange? = null + private var mapToTransformedCalled: TextRange? = null + + private var batchDepth = 0 + @Before fun setup() { ic = StatelessInputConnection(activeSession, EditorInfo()) @@ -197,7 +235,7 @@ class StatelessInputConnectionTest { var requestEditsCalled = 0 onRequestEdit = { block -> requestEditsCalled++ - state.mainBuffer.block() + state.editUntransformedTextAsUser { block() } } value = TextFieldCharSequence(text = "", selection = TextRange.Zero) @@ -214,8 +252,8 @@ class StatelessInputConnectionTest { ic.endBatchEdit() assertThat(requestEditsCalled).isEqualTo(1) - assertThat(state.mainBuffer.toString()).isEqualTo("Hello, World.") - assertThat(state.mainBuffer.selection).isEqualTo(TextRange(13)) + assertThat(state.untransformedText.toString()).isEqualTo("Hello, World.") + assertThat(state.untransformedText.selection).isEqualTo(TextRange(13)) } @OptIn(ExperimentalComposeUiApi::class) @@ -325,14 +363,10 @@ class StatelessInputConnectionTest { @Test fun setComposingText_appliesComposingSpans() { var requestEditsCalled = 0 - state = TextFieldState("hello ") + state = TransformedTextFieldState(TextFieldState("hello ")) onRequestEdit = { block -> requestEditsCalled++ - state.editAsUser( - inputTransformation = null, - restartImeIfContentChanges = false, - block = block - ) + state.editUntransformedTextAsUser { block() } } ic.setComposingText( @@ -345,9 +379,9 @@ class StatelessInputConnectionTest { ) assertThat(requestEditsCalled).isEqualTo(1) - assertThat(state.composition).isEqualTo(TextRange(6, 11)) - assertThat(state.value.composingAnnotations).isNotNull() - assertThat(state.value.composingAnnotations) + assertThat(state.untransformedText.composition).isEqualTo(TextRange(6, 11)) + assertThat(state.untransformedText.composingAnnotations).isNotNull() + assertThat(state.untransformedText.composingAnnotations) .containsExactlyElementsIn( listOf( AnnotatedString.Range( @@ -458,7 +492,7 @@ class StatelessInputConnectionTest { var requestEditsCalled = 0 onRequestEdit = { block -> requestEditsCalled++ - state.mainBuffer.block() + state.editUntransformedTextAsUser { block() } } value = TextFieldCharSequence(text = "", selection = TextRange.Zero) @@ -483,8 +517,54 @@ class StatelessInputConnectionTest { ic.endBatchEdit() assertThat(requestEditsCalled).isEqualTo(1) - assertThat(state.mainBuffer.toString()).isEqualTo(".") - assertThat(state.mainBuffer.selection).isEqualTo(TextRange(0)) + assertThat(state.untransformedText.toString()).isEqualTo(".") + assertThat(state.untransformedText.selection).isEqualTo(TextRange(0)) + } + + @Test + fun setSelection_respectsOutputTransformation() { + state = + TransformedTextFieldState( + textFieldState = TextFieldState("abc def"), + outputTransformation = { insert(4, "ghi ") } + ) + var requestEditsCalled = 0 + onRequestEdit = { block -> + requestEditsCalled++ + state.editUntransformedTextAsUser { block() } + } + + ic.beginBatchEdit() + + assertThat(ic.setSelection(5, 5)).isTrue() + assertThat(requestEditsCalled).isEqualTo(0) + + ic.endBatchEdit() + assertThat(requestEditsCalled).isEqualTo(1) + assertThat(mapFromTransformedCalled).isEqualTo(TextRange(5)) + assertThat(state.untransformedText.selection).isEqualTo(TextRange(4)) + assertThat(state.visualText.selection).isEqualTo(TextRange(4)) + assertThat(state.selectionWedgeAffinity) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) + } + + @Test + fun setSelection_coercesRange() { + state = TransformedTextFieldState(textFieldState = TextFieldState("abc def")) + var requestEditsCalled = 0 + onRequestEdit = { block -> + requestEditsCalled++ + state.editUntransformedTextAsUser { block() } + } + + ic.beginBatchEdit() + + assertThat(ic.setSelection(Int.MIN_VALUE, Int.MAX_VALUE)).isTrue() + assertThat(requestEditsCalled).isEqualTo(0) + + ic.endBatchEdit() + assertThat(requestEditsCalled).isEqualTo(1) + assertThat(state.untransformedText.selection).isEqualTo(TextRange(0, 7)) } @Test @@ -554,13 +634,13 @@ class StatelessInputConnectionTest { var callCount = 0 onRequestEdit = { block -> callCount++ - state.mainBuffer.block() + state.editUntransformedTextAsUser { block() } } ic.performContextMenuAction(android.R.id.selectAll) assertThat(callCount).isEqualTo(1) - assertThat(state.mainBuffer.selection).isEqualTo(TextRange(0, 5)) + assertThat(state.untransformedText.selection).isEqualTo(TextRange(0, 5)) } @Test diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldLayoutStateCacheTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldLayoutStateCacheTest.kt index 378855cdc48e4..0468bd4c190ad 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldLayoutStateCacheTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldLayoutStateCacheTest.kt @@ -19,6 +19,7 @@ package androidx.compose.foundation.text.input.internal import android.graphics.Typeface import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setSelectionCoerced import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -494,7 +495,7 @@ class TextFieldLayoutStateCacheTest { } assertLayoutChange( change = { - textFieldState.editAsUser(inputTransformation = null) { setComposingRegion(2, 3) } + textFieldState.editAsUser(inputTransformation = null) { setComposition(2, 3) } }, ) { old, new -> assertThat( @@ -516,7 +517,7 @@ class TextFieldLayoutStateCacheTest { fun value_returnsCachedLayout_whenCompositionDoesNotChange() { textFieldState.editAsUser(inputTransformation = null) { replace(0, length, "hello") - setSelection(0, 0) + setSelectionCoerced(0) setComposition(0, 5) } updateNonMeasureInputs() @@ -568,7 +569,7 @@ class TextFieldLayoutStateCacheTest { fun value_returnsCachedLayout_whenComposingAnnotationsDoNotChange() { textFieldState.editAsUser(inputTransformation = null) { replace(0, length, "hello") - setSelection(0, 0) + setSelectionCoerced(0) setComposition( 0, 5, diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt index 7a25e7e5b6e20..771bae5dca8ee 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt @@ -387,5 +387,9 @@ internal class TextInputServiceAndroidCursorAnchorInfoTest { Rect(localPositionOf(sourceCoordinates, Offset.Zero), sourceCoordinates.size.toSize()) override fun transformFrom(sourceCoordinates: LayoutCoordinates, matrix: Matrix) {} + + override fun transformToScreen(matrix: Matrix) { + matrix.translate(windowOffset.x, windowOffset.y, 0f) + } } } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt index 9fd4053e89874..425cd334c1a58 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt @@ -30,13 +30,17 @@ import androidx.compose.foundation.text.Handle import androidx.compose.foundation.text.TEST_FONT_FAMILY import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.internal.CodepointTransformation +import androidx.compose.foundation.text.input.internal.mask import androidx.compose.foundation.text.input.placeCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.selection.assertHandlePositionMatches import androidx.compose.foundation.text.selection.isSelectionHandle import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onSizeChanged @@ -46,6 +50,7 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.click import androidx.compose.ui.test.hasPerformImeAction import androidx.compose.ui.test.isDisplayed @@ -586,6 +591,32 @@ class TextFieldCursorHandleTest : FocusedWindowTest { rule.onNode(isSelectionHandle(Handle.Cursor)).assertIsDisplayed() } + @Test + fun cursorHandle_disappears_whenCodepointTransformationChanges() { + state = TextFieldState("hello") + + var codepointTransformation: CodepointTransformation? by mutableStateOf(null) + + rule.setContent { + BasicTextField( + state, + textStyle = TextStyle(fontSize = fontSize, fontFamily = TEST_FONT_FAMILY), + modifier = Modifier.testTag(TAG), + codepointTransformation = codepointTransformation, + ) + } + + focusAndWait() + + rule.onNodeWithTag(TAG).performClick() + rule.onNode(isSelectionHandle(Handle.Cursor)).assertIsDisplayed() + + codepointTransformation = CodepointTransformation.mask('c') + + rule.waitForIdle() + rule.onNode(isSelectionHandle(Handle.Cursor)).assertIsNotDisplayed() + } + @Test fun cursorHandleDrag_getsFiltered() { state = TextFieldState("abc abc") diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt index a123b1649a4cc..961232cebd8ee 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt @@ -152,7 +152,7 @@ class TextFieldLongPressTest : FocusedWindowTest { val state = TextFieldState("abc") var showMenuCalled = 0 val textToolbar = - FakeTextToolbar(onShowMenu = { _, _, _, _, _ -> showMenuCalled++ }, onHideMenu = {}) + FakeTextToolbar(onShowMenu = { _, _, _, _, _, _ -> showMenuCalled++ }, onHideMenu = {}) val clipboardManager = FakeClipboardManager("hello") rule.setTextFieldTestContent { CompositionLocalProvider( diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt index 7d06ec9b6a70a..d8ee9129d9349 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt @@ -138,7 +138,7 @@ class TextFieldTextToolbarTest : FocusedWindowTest { @Test fun longClickOnEmptyTextField_showsToolbar_butNoHandle() { val state = TextFieldState("") - val textToolbar = FakeTextToolbar({ _, _, _, _, _ -> }, {}) + val textToolbar = FakeTextToolbar({ _, _, _, _, _, _ -> }, {}) setupContent(state, textToolbar) rule.onNodeWithTag(TAG).performTouchInput { @@ -415,7 +415,10 @@ class TextFieldTextToolbarTest : FocusedWindowTest { fun toolbarFollowsTheCursor_whenTextFieldIsScrolled() { var shownRect: Rect? = null val textToolbar = - FakeTextToolbar(onShowMenu = { rect, _, _, _, _ -> shownRect = rect }, onHideMenu = {}) + FakeTextToolbar( + onShowMenu = { rect, _, _, _, _, _ -> shownRect = rect }, + onHideMenu = {} + ) val state = TextFieldState("Hello ".repeat(20)) // make sure the field is scrollable setupContent(state, textToolbar, true) @@ -450,7 +453,7 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var selectAllOptionAvailable = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, _, _, onSelectAllRequested -> + onShowMenu = { _, _, _, _, onSelectAllRequested, _ -> selectAllOptionAvailable = onSelectAllRequested != null }, onHideMenu = {} @@ -464,12 +467,32 @@ class TextFieldTextToolbarTest : FocusedWindowTest { rule.runOnIdle { assertThat(selectAllOptionAvailable).isTrue() } } + @Test + fun toolbarShowsAutofill_ifNotReadOnly() { + var autofillOptionAvailable = false + val textToolbar = + FakeTextToolbar( + onShowMenu = { _, _, _, _, _, onAutofillRequested -> + autofillOptionAvailable = onAutofillRequested != null + }, + onHideMenu = {} + ) + val state = TextFieldState("Hello") + setupContent(state, textToolbar, readOnly = false) + + rule.onNodeWithTag(TAG).performTouchInput { click(Offset(fontSizePx * 2, fontSizePx / 2)) } + rule.onNode(isSelectionHandle(Handle.Cursor)).performClick() + + rule.runOnIdle { assertThat(textToolbar.status).isEqualTo(TextToolbarStatus.Shown) } + rule.runOnIdle { assertThat(autofillOptionAvailable).isTrue() } + } + @Test fun toolbarDoesNotShowSelectAll_whenAllTextIsAlreadySelected() { var selectAllOption: (() -> Unit)? = null val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, _, _, onSelectAllRequested -> + onShowMenu = { _, _, _, _, onSelectAllRequested, _ -> selectAllOption = onSelectAllRequested }, onHideMenu = {} @@ -493,13 +516,13 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var pasteOptionAvailable = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, onPasteRequested, _, _ -> + onShowMenu = { _, _, onPasteRequested, _, _, _ -> pasteOptionAvailable = onPasteRequested != null }, onHideMenu = {} ) val state = TextFieldState("Hello") - setupContent(state, textToolbar, true) + setupContent(state = state, toolbar = textToolbar, singleLine = true) rule.onNodeWithTag(TAG).performTouchInput { click() } rule.onNode(isSelectionHandle(Handle.Cursor)).performClick() @@ -512,14 +535,19 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var pasteOptionAvailable = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, onPasteRequested, _, _ -> + onShowMenu = { _, _, onPasteRequested, _, _, _ -> pasteOptionAvailable = onPasteRequested != null }, onHideMenu = {} ) val clipboardManager = FakeClipboardManager("world") val state = TextFieldState("Hello") - setupContent(state, textToolbar, true, clipboardManager) + setupContent( + state = state, + toolbar = textToolbar, + singleLine = true, + clipboardManager = clipboardManager + ) rule.onNodeWithTag(TAG).performTouchInput { click() } rule.onNode(isSelectionHandle(Handle.Cursor)).performClick() @@ -532,7 +560,7 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var pasteOptionAvailable = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, onPasteRequested, _, _ -> + onShowMenu = { _, _, onPasteRequested, _, _, _ -> pasteOptionAvailable = onPasteRequested != null }, onHideMenu = {} @@ -542,7 +570,12 @@ class TextFieldTextToolbarTest : FocusedWindowTest { setClip(createClipData().toClipEntry()) } val state = TextFieldState("Hello") - setupContent(state, textToolbar, true, clipboardManager) + setupContent( + state = state, + toolbar = textToolbar, + singleLine = true, + clipboardManager = clipboardManager + ) rule.onNodeWithTag(TAG).performTouchInput { click() } rule.onNode(isSelectionHandle(Handle.Cursor)).performClick() @@ -555,7 +588,7 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var pasteOptionAvailable = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, onPasteRequested, _, _ -> + onShowMenu = { _, _, onPasteRequested, _, _, _ -> pasteOptionAvailable = onPasteRequested != null }, onHideMenu = {} @@ -584,12 +617,17 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var pasteOption: (() -> Unit)? = null val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, onPasteRequested, _, _ -> pasteOption = onPasteRequested }, + onShowMenu = { _, _, onPasteRequested, _, _, _ -> pasteOption = onPasteRequested }, onHideMenu = {} ) val clipboardManager = FakeClipboardManager("world") val state = TextFieldState("Hello") - setupContent(state, textToolbar, true, clipboardManager) + setupContent( + state = state, + toolbar = textToolbar, + singleLine = true, + clipboardManager = clipboardManager + ) rule.onNodeWithTag(TAG).performTouchInput { click(Offset(fontSizePx * 2, 0f)) } rule.onNode(isSelectionHandle(Handle.Cursor)).performClick() @@ -609,7 +647,7 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var copyOptionAvailable = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, onCopyRequested, _, onCutRequested, _ -> + onShowMenu = { _, onCopyRequested, _, onCutRequested, _, _ -> copyOptionAvailable = onCopyRequested != null cutOptionAvailable = onCutRequested != null }, @@ -633,7 +671,7 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var copyOptionAvailable = false val textToolbar = FakeTextToolbar( - onShowMenu = { _, onCopyRequested, _, onCutRequested, _ -> + onShowMenu = { _, onCopyRequested, _, onCutRequested, _, _ -> copyOptionAvailable = onCopyRequested != null cutOptionAvailable = onCutRequested != null }, @@ -651,17 +689,63 @@ class TextFieldTextToolbarTest : FocusedWindowTest { } } + @Test + fun toolbarShowsAutofill_whenSelectionIsCollapsed() { + var autofillOptionAvailable = false + + val textToolbar = + FakeTextToolbar( + onShowMenu = { _, _, _, _, _, onAutofillRequested -> + autofillOptionAvailable = onAutofillRequested != null + }, + onHideMenu = {} + ) + val state = TextFieldState("Hello") + setupContent(state, textToolbar, true) + + rule.onNodeWithTag(TAG).performTouchInput { click() } + rule.onNode(isSelectionHandle(Handle.Cursor)).performClick() + rule.onNodeWithTag(TAG).performTextInputSelectionShowingToolbar(TextRange(2, 2)) + + rule.runOnIdle { assertThat(autofillOptionAvailable).isTrue() } + } + + @Test + fun toolbarDoesNotShowAutofill_whenSelectionIsExpanded() { + var autofillOptionAvailable = false + val textToolbar = + FakeTextToolbar( + onShowMenu = { _, _, _, _, _, onAutofillRequested -> + autofillOptionAvailable = onAutofillRequested != null + }, + onHideMenu = {} + ) + val state = TextFieldState("Hello") + setupContent(state, textToolbar, true) + + rule.onNodeWithTag(TAG).requestFocus() + rule.onNodeWithTag(TAG).performTextInputSelectionShowingToolbar(TextRange(2, 4)) + + // Autofill should not display when text has been selected. + rule.runOnIdle { assertThat(autofillOptionAvailable).isFalse() } + } + @Test fun copyUpdatesClipboardManager_placesCursorAtTheEndOfSelectedRegion() { var copyOption: (() -> Unit)? = null val textToolbar = FakeTextToolbar( - onShowMenu = { _, onCopyRequested, _, _, _ -> copyOption = onCopyRequested }, + onShowMenu = { _, onCopyRequested, _, _, _, _ -> copyOption = onCopyRequested }, onHideMenu = {} ) val clipboardManager = FakeClipboardManager() val state = TextFieldState("Hello") - setupContent(state, textToolbar, true, clipboardManager) + setupContent( + state = state, + toolbar = textToolbar, + singleLine = true, + clipboardManager = clipboardManager + ) rule.onNodeWithTag(TAG).requestFocus() rule.onNodeWithTag(TAG).performTextInputSelectionShowingToolbar(TextRange(0, 5)) @@ -679,12 +763,17 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var cutOption: (() -> Unit)? = null val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, _, onCutRequested, _ -> cutOption = onCutRequested }, + onShowMenu = { _, _, _, onCutRequested, _, _ -> cutOption = onCutRequested }, onHideMenu = {} ) val clipboardManager = FakeClipboardManager() val state = TextFieldState("Hello World!") - setupContent(state, textToolbar, true, clipboardManager) + setupContent( + state = state, + toolbar = textToolbar, + singleLine = true, + clipboardManager = clipboardManager + ) rule.onNodeWithTag(TAG).requestFocus() rule.onNodeWithTag(TAG).performTextInputSelectionShowingToolbar(TextRange(1, 5)) @@ -703,12 +792,17 @@ class TextFieldTextToolbarTest : FocusedWindowTest { var cutOption: (() -> Unit)? = null val textToolbar = FakeTextToolbar( - onShowMenu = { _, _, _, onCutRequested, _ -> cutOption = onCutRequested }, + onShowMenu = { _, _, _, onCutRequested, _, _ -> cutOption = onCutRequested }, onHideMenu = {} ) val clipboardManager = FakeClipboardManager() val state = TextFieldState("Hello World!") - setupContent(state, textToolbar, true, clipboardManager) { + setupContent( + state = state, + toolbar = textToolbar, + singleLine = true, + clipboardManager = clipboardManager + ) { // only reject text changes, accept selection val initialSelection = selection replace(0, length, originalValue.toString()) @@ -832,6 +926,7 @@ class TextFieldTextToolbarTest : FocusedWindowTest { state: TextFieldState = TextFieldState(), toolbar: TextToolbar = FakeTextToolbar(), singleLine: Boolean = false, + readOnly: Boolean = false, clipboardManager: ClipboardManager = FakeClipboardManager(), modifier: Modifier = Modifier, filter: InputTransformation? = null, @@ -853,14 +948,15 @@ class TextFieldTextToolbarTest : FocusedWindowTest { } else { TextFieldLineLimits.Default }, - inputTransformation = filter + inputTransformation = filter, + readOnly = readOnly ) } } } private fun FakeTextToolbar() = - FakeTextToolbar(onShowMenu = { _, _, _, _, _ -> }, onHideMenu = { println("hide") }) + FakeTextToolbar(onShowMenu = { _, _, _, _, _, _ -> }, onHideMenu = { println("hide") }) } internal class RectSubject diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTestUtils.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTestUtils.kt index e4ffd93565a71..da5373922315d 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTestUtils.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTestUtils.kt @@ -16,8 +16,7 @@ package androidx.compose.foundation.text.modifiers -import androidx.compose.foundation.text.AutoSize -import androidx.compose.foundation.text.FontSizeSearchScope +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp @@ -27,8 +26,8 @@ import androidx.compose.ui.unit.sp * * @param presets The array of font sizes to be checked */ -internal class AutoSizePreset(private val presets: Array) : AutoSize { - override fun FontSizeSearchScope.getFontSize(): TextUnit { +internal class AutoSizePreset(private val presets: Array) : TextAutoSize { + override fun AutoSizeTextLayoutScope.getFontSize(): TextUnit { var optimalFontSize = 0.sp for (size in presets) { if ( @@ -55,14 +54,14 @@ internal class AutoSizePreset(private val presets: Array) : AutoSize { } /** - * [AutoSize] class with a binary implementation where `100.sp` is returned if the font size given - * doesn't overflow, and `0.sp` if the font size does overflow. + * [TextAutoSize] class with a binary implementation where `100.sp` is returned if the font size + * given doesn't overflow, and `0.sp` if the font size does overflow. * * The aim of this class is to perform AutoSize without using density methods like `toPx()` to check * if [TextUnit.Unspecified] works correctly with `performLayoutAndGetOverflow()` */ -internal class AutoSizeWithoutToPx(private val fontSize: TextUnit) : AutoSize { - override fun FontSizeSearchScope.getFontSize(): TextUnit { +internal class AutoSizeWithoutToPx(private val fontSize: TextUnit) : TextAutoSize { + override fun AutoSizeTextLayoutScope.getFontSize(): TextUnit { // if there is overflow then 100.sp is returned. Otherwise 0.sp is returned if (performLayoutAndGetOverflow(fontSize)) return 100.sp return 0.sp diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCacheTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCacheTest.kt index 7ae1502ca2f45..e2fad1ae3fb3f 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCacheTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCacheTest.kt @@ -19,6 +19,7 @@ package androidx.compose.foundation.text.modifiers import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines import androidx.compose.foundation.text.TEST_FONT_FAMILY +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.toIntPx import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Paragraph @@ -401,7 +402,7 @@ class MultiParagraphLayoutCacheTest { style = TextStyle(fontSize = 20.sp, fontFamily = fontFamily), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Clip, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -426,7 +427,7 @@ class MultiParagraphLayoutCacheTest { style = TextStyle(fontFamily = fontFamily), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Clip, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -450,7 +451,7 @@ class MultiParagraphLayoutCacheTest { style = TextStyle(fontSize = 20.sp, fontFamily = fontFamily), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Clip, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -480,7 +481,7 @@ class MultiParagraphLayoutCacheTest { style = TextStyle(fontFamily = fontFamily), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Ellipsis, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -509,7 +510,7 @@ class MultiParagraphLayoutCacheTest { style = TextStyle(fontFamily = fontFamily), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Visible, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -675,7 +676,7 @@ class MultiParagraphLayoutCacheTest { style = TextStyle(fontFamily = fontFamily), fontFamilyResolver = fontFamilyResolver, minLines = 2, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -739,7 +740,7 @@ class MultiParagraphLayoutCacheTest { private fun MultiParagraphLayoutCache.updateAutoSize( text: String, fontSize: TextUnit, - autoSize: AutoSize + autoSize: TextAutoSize ) = update( text = AnnotatedString(text), diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCacheTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCacheTest.kt index 3ce7aaa5f179d..3e2fd6432909e 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCacheTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCacheTest.kt @@ -19,6 +19,7 @@ package androidx.compose.foundation.text.modifiers import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines import androidx.compose.foundation.text.TEST_FONT_FAMILY +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.toIntPx import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.Paragraph @@ -408,7 +409,7 @@ class ParagraphLayoutCacheTest { style = createTextStyle(20.sp), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Clip, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -432,7 +433,7 @@ class ParagraphLayoutCacheTest { style = createTextStyle(20.sp), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Clip, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -461,7 +462,7 @@ class ParagraphLayoutCacheTest { style = createTextStyle(TextUnit.Unspecified), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Clip, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -485,7 +486,7 @@ class ParagraphLayoutCacheTest { style = createTextStyle(TextUnit.Unspecified), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Ellipsis, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -510,7 +511,7 @@ class ParagraphLayoutCacheTest { style = createTextStyle(TextUnit.Unspecified), fontFamilyResolver = fontFamilyResolver, overflow = TextOverflow.Visible, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -670,7 +671,7 @@ class ParagraphLayoutCacheTest { style = createTextStyle(TextUnit.Unspecified), fontFamilyResolver = fontFamilyResolver, minLines = 2, - autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) + autoSize = AutoSize.StepBased(20.sp, 51.sp, 1.sp) as TextAutoSize ) .also { it.density = density } @@ -765,7 +766,7 @@ class ParagraphLayoutCacheTest { private fun ParagraphLayoutCache.updateAutoSize( text: String, fontSize: TextUnit, - autoSize: AutoSize + autoSize: TextAutoSize ) = update( text = text, diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt index 3280db66b8aa7..6ebdd3f052ec4 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt @@ -182,6 +182,7 @@ class SelectionContainerContextMenuTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -197,6 +198,7 @@ class SelectionContainerContextMenuTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -212,6 +214,7 @@ class SelectionContainerContextMenuTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerFocusTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerFocusTest.kt index b2c14ea851bda..c193490d809b1 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerFocusTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerFocusTest.kt @@ -140,7 +140,7 @@ class SelectionContainerFocusTest { var lastShowCalled: Boolean? = null val fakeTextToolbar = FakeTextToolbar( - onShowMenu = { _, _, _, _, _ -> lastShowCalled = true }, + onShowMenu = { _, _, _, _, _, _ -> lastShowCalled = true }, onHideMenu = { lastShowCalled = false } ) @@ -242,7 +242,8 @@ internal fun FakeTextToolbar( onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, onCutRequested: (() -> Unit)?, - onSelectAllRequested: (() -> Unit)? + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)? ) -> Unit, onHideMenu: () -> Unit ): TextToolbar { @@ -254,18 +255,30 @@ internal fun FakeTextToolbar( onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, onCutRequested: (() -> Unit)?, - onSelectAllRequested: (() -> Unit)? + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)? ) { onShowMenu( rect, onCopyRequested, onPasteRequested, onCutRequested, - onSelectAllRequested + onSelectAllRequested, + onAutofillRequested ) _status = TextToolbarStatus.Shown } + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)? + ) { + _status = TextToolbarStatus.Shown + } + override fun hide() { onHideMenu() _status = TextToolbarStatus.Hidden diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerTest.kt index d6aa5e25be999..5dce1c6cf29cd 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerTest.kt @@ -16,14 +16,18 @@ package androidx.compose.foundation.text.selection +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.Handle import androidx.compose.foundation.text.TEST_FONT_FAMILY +import androidx.compose.foundation.text.test.assertThatIntRect import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf @@ -42,9 +46,10 @@ import androidx.compose.ui.input.pointer.PointerInputFilter import androidx.compose.ui.input.pointer.PointerInputModifier import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag @@ -71,17 +76,20 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.ResolvedTextDirection import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.width import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.filters.SdkSuppress import com.google.common.truth.Truth.assertThat -import java.util.concurrent.CountDownLatch import kotlin.math.max import kotlin.math.sign import org.junit.Rule @@ -444,6 +452,61 @@ class SelectionContainerTest { assertAnchorInfo(selection.value?.end, offset = 5, selectableId = 3) } + /** + * Regression test for b/372053402 - Modifier.weight not working on SelectionContainer. + * + * Lay out a selection container with a weight next to a fixed size box in a row. We expect the + * selection container to take up the remaining space in the row that the box does not use. + */ + @Test + fun selectionContainer_layoutWeightApplies() { + val density = Density(1f) + with(density) { + val rowSize = 100 + val boxSize = 10 + val expectedSelConWidth = rowSize - boxSize + + val rowTag = "row" + val boxTag = "box" + val selConTag = "sel" + val textTag = "text" + + rule.setContent { + CompositionLocalProvider(LocalDensity provides density) { + Row(Modifier.size(rowSize.toDp()).testTag(rowTag)) { + SelectionContainer(Modifier.weight(1f).testTag(selConTag)) { + TestText("text ".repeat(100).trim(), Modifier.testTag(textTag)) + } + Box(Modifier.size(boxSize.toDp()).testTag(boxTag)) + } + } + } + + fun layoutCoordinatesForTag(tag: String): LayoutCoordinates = + rule.onNodeWithTag(tag).fetchSemanticsNode().layoutInfo.coordinates + + val rowCoords = layoutCoordinatesForTag(rowTag) + fun boundsInRow(tag: String): IntRect = + rowCoords + .localBoundingBoxOf(layoutCoordinatesForTag(tag), clipBounds = false) + .roundToIntRect() + + val rowBounds = IntRect(IntOffset.Zero, rowCoords.size) + val selConBounds = boundsInRow(selConTag) + val textBounds = boundsInRow(textTag) + val boxBounds = boundsInRow(boxTag) + + assertThatIntRect(rowBounds) + .isEqualTo(top = 0, left = 0, right = rowSize, bottom = rowSize) + assertThatIntRect(selConBounds) + .isEqualTo(top = 0, left = 0, right = expectedSelConWidth, bottom = rowSize) + assertThatIntRect(textBounds) + .isEqualTo(top = 0, left = 0, right = expectedSelConWidth, bottom = rowSize) + assertThatIntRect(boxBounds) + .isEqualTo(top = 0, left = expectedSelConWidth, right = rowSize, bottom = boxSize) + } + } + @Test @OptIn(ExperimentalTestApi::class) fun selection_doesCopy_whenCopyKeyEventSent() { @@ -527,8 +590,6 @@ class SelectionContainerTest { isRtl: Boolean = false, content: (@Composable () -> Unit)? = null ) { - val measureLatch = CountDownLatch(1) - val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr rule.setContent { CompositionLocalProvider( @@ -537,25 +598,30 @@ class SelectionContainerTest { ) { TestParent(Modifier.testTag("selectionContainer").gestureSpy(log)) { SelectionContainer( - modifier = Modifier.onGloballyPositioned { measureLatch.countDown() }, selection = selection.value, onSelectionChange = { selection.value = it } ) { - content?.invoke() - ?: BasicText( - AnnotatedString(textContent), - Modifier.fillMaxSize(), - style = TextStyle(fontFamily = fontFamily, fontSize = fontSize), - softWrap = true, - overflow = TextOverflow.Clip, - maxLines = Int.MAX_VALUE, - inlineContent = mapOf(), - onTextLayout = {} - ) + content?.invoke() ?: TestText(textContent, Modifier.fillMaxSize()) } } } } + + rule.waitForIdle() + } + + @Composable + private fun TestText(text: String, modifier: Modifier = Modifier) { + BasicText( + text = AnnotatedString(text), + modifier = modifier, + style = TextStyle(fontFamily = fontFamily, fontSize = fontSize), + softWrap = true, + overflow = TextOverflow.Clip, + maxLines = Int.MAX_VALUE, + inlineContent = mapOf(), + onTextLayout = {} + ) } } diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/gestures/LazyColumnMultiTextRegressionTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/gestures/LazyColumnMultiTextRegressionTest.kt index 2301330b48ec2..783aeefca0bb9 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/gestures/LazyColumnMultiTextRegressionTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/gestures/LazyColumnMultiTextRegressionTest.kt @@ -401,6 +401,26 @@ private class TextToolbarWrapper(private val delegate: TextToolbar) : TextToolba val mostRecentRect: Rect? get() = _mostRecentRect + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)? + ) { + _shown = true + _mostRecentRect = rect + delegate.showMenu( + rect, + onCopyRequested, + onPasteRequested, + onCutRequested, + onSelectAllRequested, + onAutofillRequested + ) + } + override fun showMenu( rect: Rect, onCopyRequested: (() -> Unit)?, diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/test/IntRectSubject.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/test/IntRectSubject.kt new file mode 100644 index 0000000000000..f6b5eca8e2b0d --- /dev/null +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/test/IntRectSubject.kt @@ -0,0 +1,48 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text.test + +import androidx.compose.ui.unit.IntRect +import com.google.common.truth.Fact.simpleFact +import com.google.common.truth.FailureMetadata +import com.google.common.truth.Subject +import com.google.common.truth.Subject.Factory +import com.google.common.truth.Truth.assertAbout + +internal fun assertThatIntRect(rect: IntRect?): IntRectSubject { + return assertAbout(IntRectSubject.SUBJECT_FACTORY).that(rect)!! +} + +internal class IntRectSubject +private constructor(failureMetadata: FailureMetadata?, private val subject: IntRect?) : + Subject(failureMetadata, subject) { + companion object { + internal val SUBJECT_FACTORY: Factory = + Factory { failureMetadata, subject -> + IntRectSubject(failureMetadata, subject) + } + } + + fun isEqualTo(left: Int, top: Int, right: Int, bottom: Int) { + if (subject == null) failWithoutActual(simpleFact("is null")) + check("instanceOf()").that(subject!!).isInstanceOf(IntRect::class.java) + check("left").that(subject.left).isEqualTo(left) + check("top").that(subject.top).isEqualTo(top) + check("right").that(subject.right).isEqualTo(right) + check("bottom").that(subject.bottom).isEqualTo(bottom) + } +} diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt index a4c56a341f255..f79f7644e8127 100644 --- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt +++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.lerp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -230,7 +231,8 @@ class TextFieldContextMenuTest : FocusedWindowTest { // region BTF1 Context Menu Correct Item Tests @Test - fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch() = + @SdkSuppress(maxSdkVersion = 25) + fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch_beforeApi26() = runBtf1CorrectItemsTest( isEmptyClipboard = true, selectionAmount = SelectionAmount.NONE, @@ -240,6 +242,23 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, + ) + } + + @Test + @SdkSuppress(minSdkVersion = 26) + fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch_afterApi26() = + runBtf1CorrectItemsTest( + isEmptyClipboard = true, + selectionAmount = SelectionAmount.NONE, + ) { + rule.assertContextMenuItems( + cutState = ContextMenuItemState.DOES_NOT_EXIST, + copyState = ContextMenuItemState.DOES_NOT_EXIST, + pasteState = ContextMenuItemState.DOES_NOT_EXIST, + selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.ENABLED, ) } @@ -254,6 +273,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -268,11 +288,13 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @Test - fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch() = + @SdkSuppress(maxSdkVersion = 25) + fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_beforeApi26() = runBtf1CorrectItemsTest( isEmptyClipboard = false, selectionAmount = SelectionAmount.NONE, @@ -282,6 +304,23 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, + ) + } + + @Test + @SdkSuppress(minSdkVersion = 26) + fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_afterApi26() = + runBtf1CorrectItemsTest( + isEmptyClipboard = false, + selectionAmount = SelectionAmount.NONE, + ) { + rule.assertContextMenuItems( + cutState = ContextMenuItemState.DOES_NOT_EXIST, + copyState = ContextMenuItemState.DOES_NOT_EXIST, + pasteState = ContextMenuItemState.ENABLED, + selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.ENABLED, ) } @@ -298,6 +337,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -312,11 +352,29 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, + ) + } + + @Test + @SdkSuppress(maxSdkVersion = 25) + fun btf1_contextMenu_password_noSelection_itemsMatch_beforeApi26() = + runBtf1CorrectItemsTest( + isPassword = true, + selectionAmount = SelectionAmount.NONE, + ) { + rule.assertContextMenuItems( + cutState = ContextMenuItemState.DOES_NOT_EXIST, + copyState = ContextMenuItemState.DOES_NOT_EXIST, + pasteState = ContextMenuItemState.ENABLED, + selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @Test - fun btf1_contextMenu_password_noSelection_itemsMatch() = + @SdkSuppress(minSdkVersion = 26) + fun btf1_contextMenu_password_noSelection_itemsMatch_afterApi26() = runBtf1CorrectItemsTest( isPassword = true, selectionAmount = SelectionAmount.NONE, @@ -326,6 +384,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.ENABLED, ) } @@ -340,6 +399,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -354,6 +414,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -369,6 +430,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -384,6 +446,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -399,6 +462,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -597,7 +661,8 @@ class TextFieldContextMenuTest : FocusedWindowTest { // region BTF2 Context Menu Correct Item Tests @Test - fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch() = + @SdkSuppress(maxSdkVersion = 25) + fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch_beforeApi26() = runBtf2CorrectItemsTest( isEmptyClipboard = true, selectionAmount = SelectionAmount.NONE, @@ -607,6 +672,23 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, + ) + } + + @Test + @SdkSuppress(minSdkVersion = 26) + fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch_afterApi26() = + runBtf2CorrectItemsTest( + isEmptyClipboard = true, + selectionAmount = SelectionAmount.NONE, + ) { + rule.assertContextMenuItems( + cutState = ContextMenuItemState.DOES_NOT_EXIST, + copyState = ContextMenuItemState.DOES_NOT_EXIST, + pasteState = ContextMenuItemState.DOES_NOT_EXIST, + selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.ENABLED, ) } @@ -621,6 +703,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -635,11 +718,13 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @Test - fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch() = + @SdkSuppress(maxSdkVersion = 25) + fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_beforeApi26() = runBtf2CorrectItemsTest( isEmptyClipboard = false, selectionAmount = SelectionAmount.NONE, @@ -649,6 +734,23 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, + ) + } + + @Test + @SdkSuppress(minSdkVersion = 26) + fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_afterApi26() = + runBtf2CorrectItemsTest( + isEmptyClipboard = false, + selectionAmount = SelectionAmount.NONE, + ) { + rule.assertContextMenuItems( + cutState = ContextMenuItemState.DOES_NOT_EXIST, + copyState = ContextMenuItemState.DOES_NOT_EXIST, + pasteState = ContextMenuItemState.ENABLED, + selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.ENABLED, ) } @@ -663,6 +765,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -677,11 +780,29 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, + ) + } + + @Test + @SdkSuppress(maxSdkVersion = 25) + fun btf2_contextMenu_password_noSelection_itemsMatch_beforeApi26() = + runBtf2CorrectItemsTest( + isPassword = true, + selectionAmount = SelectionAmount.NONE, + ) { + rule.assertContextMenuItems( + cutState = ContextMenuItemState.DOES_NOT_EXIST, + copyState = ContextMenuItemState.DOES_NOT_EXIST, + pasteState = ContextMenuItemState.ENABLED, + selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @Test - fun btf2_contextMenu_password_noSelection_itemsMatch() = + @SdkSuppress(minSdkVersion = 26) + fun btf2_contextMenu_password_noSelection_itemsMatch_afterApi26() = runBtf2CorrectItemsTest( isPassword = true, selectionAmount = SelectionAmount.NONE, @@ -691,6 +812,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.ENABLED, ) } @@ -705,6 +827,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -719,6 +842,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.ENABLED, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -734,6 +858,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.DOES_NOT_EXIST, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -749,6 +874,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.ENABLED, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } @@ -764,6 +890,7 @@ class TextFieldContextMenuTest : FocusedWindowTest { copyState = ContextMenuItemState.ENABLED, pasteState = ContextMenuItemState.DOES_NOT_EXIST, selectAllState = ContextMenuItemState.DOES_NOT_EXIST, + autofillState = ContextMenuItemState.DOES_NOT_EXIST, ) } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidExternalSurface.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidExternalSurface.android.kt index 178364f781422..0ac727a54dfe8 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidExternalSurface.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidExternalSurface.android.kt @@ -435,13 +435,7 @@ fun AndroidEmbeddedExternalSurface( val state = rememberAndroidEmbeddedExternalSurfaceState() AndroidView( - factory = { context -> - TextureView(context).apply { - state.surfaceSize = surfaceSize - state.onInit() - surfaceTextureListener = state - } - }, + factory = { TextureView(it) }, modifier = modifier, onReset = {}, update = { view -> @@ -449,6 +443,10 @@ fun AndroidEmbeddedExternalSurface( view.surfaceTexture?.setDefaultBufferSize(surfaceSize.width, surfaceSize.height) } state.surfaceSize = surfaceSize + if (view.surfaceTextureListener !== state) { + state.onInit() + view.surfaceTextureListener = state + } view.isOpaque = isOpaque // If transform is null, we'll call setTransform(null) which sets the // identity transform on the TextureView diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt index a9127f32022de..410f06461df68 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt @@ -31,17 +31,18 @@ import androidx.compose.foundation.EdgeEffectCompat.onReleaseWithOppositeDelta import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalAccessorScope import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.DrawModifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.center import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.NativeCanvas import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope @@ -51,12 +52,12 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.pointer.PointerId -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.InspectorValueInfo +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity @@ -64,26 +65,106 @@ import androidx.compose.ui.util.fastFilter import androidx.compose.ui.util.fastFirstOrNull import kotlin.math.roundToInt +/** + * Creates and remembers an instance of the platform [OverscrollFactory], with the provided + * [glowColor] and [glowDrawPadding] values - these values will be used on platform versions where + * glow overscroll is used. + * + * The OverscrollFactory returned from this function should be provided near the top of your + * application to [LocalOverscrollFactory], in order to apply this across all components in your + * application. + * + * @param glowColor color for the glow effect if the platform effect is a glow effect, otherwise + * ignored. + * @param glowDrawPadding the amount of padding to apply from the overscroll bounds to the effect + * before drawing it if the platform effect is a glow effect, otherwise ignored. + */ @Composable -internal actual fun rememberOverscrollEffect(): OverscrollEffect { +fun rememberPlatformOverscrollFactory( + glowColor: Color = DefaultGlowColor, + glowDrawPadding: PaddingValues = DefaultGlowPaddingValues +): OverscrollFactory { + val context = LocalContext.current + val density = LocalDensity.current + return AndroidEdgeEffectOverscrollFactory(context, density, glowColor, glowDrawPadding) +} + +@OptIn(ExperimentalFoundationApi::class) +@Suppress("DEPRECATION") +internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? { + val context = LocalContext.currentValue + val density = LocalDensity.currentValue + val config = LocalOverscrollConfiguration.currentValue + return if (config == null) { + null + } else { + AndroidEdgeEffectOverscrollFactory(context, density, config.glowColor, config.drawPadding) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Suppress("DEPRECATION") +@Composable +internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? { val context = LocalContext.current val density = LocalDensity.current val config = LocalOverscrollConfiguration.current - return if (config != null) { + return if (config == null) { + null + } else { remember(context, density, config) { - AndroidEdgeEffectOverscrollEffect(context, density, config) + AndroidEdgeEffectOverscrollEffect( + context, + density, + config.glowColor, + config.drawPadding + ) } - } else { - NoOpOverscrollEffect + } +} + +private class AndroidEdgeEffectOverscrollFactory( + private val context: Context, + private val density: Density, + private val glowColor: Color = DefaultGlowColor, + private val glowDrawPadding: PaddingValues = DefaultGlowPaddingValues +) : OverscrollFactory { + override fun createOverscrollEffect(): OverscrollEffect { + return AndroidEdgeEffectOverscrollEffect(context, density, glowColor, glowDrawPadding) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AndroidEdgeEffectOverscrollFactory + + if (context != other.context) return false + if (density != other.density) return false + if (glowColor != other.glowColor) return false + if (glowDrawPadding != other.glowDrawPadding) return false + + return true + } + + override fun hashCode(): Int { + var result = context.hashCode() + result = 31 * result + density.hashCode() + result = 31 * result + glowColor.hashCode() + result = 31 * result + glowDrawPadding.hashCode() + return result } } @RequiresApi(Build.VERSION_CODES.S) -private class DrawStretchOverscrollModifier( +private class StretchOverscrollNode( + pointerInputNode: DelegatableNode, private val overscrollEffect: AndroidEdgeEffectOverscrollEffect, private val edgeEffectWrapper: EdgeEffectWrapper, - inspectorInfo: InspectorInfo.() -> Unit -) : DrawModifier, InspectorValueInfo(inspectorInfo) { +) : DelegatingNode(), DrawModifierNode { + init { + delegate(pointerInputNode) + } /** * There is an unwanted behavior in the stretch overscroll effect we have to workaround: when @@ -325,12 +406,15 @@ private class DrawStretchOverscrollModifier( } } -private class DrawGlowOverscrollModifier( +private class GlowOverscrollNode( + pointerInputNode: DelegatableNode, private val overscrollEffect: AndroidEdgeEffectOverscrollEffect, private val edgeEffectWrapper: EdgeEffectWrapper, - private val overscrollConfig: OverscrollConfiguration, - inspectorInfo: InspectorInfo.() -> Unit -) : DrawModifier, InspectorValueInfo(inspectorInfo) { + private val glowDrawPadding: PaddingValues, +) : DelegatingNode(), DrawModifierNode { + init { + delegate(pointerInputNode) + } @Suppress("KotlinConstantConditions") override fun ContentDrawScope.draw() { @@ -367,10 +451,7 @@ private class DrawGlowOverscrollModifier( private fun DrawScope.drawLeftGlow(left: EdgeEffect, canvas: NativeCanvas): Boolean { val offset = - Offset( - -size.height, - overscrollConfig.drawPadding.calculateLeftPadding(layoutDirection).toPx() - ) + Offset(-size.height, glowDrawPadding.calculateLeftPadding(layoutDirection).toPx()) return drawWithRotationAndOffset( rotationDegrees = 270f, offset = offset, @@ -380,7 +461,7 @@ private class DrawGlowOverscrollModifier( } private fun DrawScope.drawTopGlow(top: EdgeEffect, canvas: NativeCanvas): Boolean { - val offset = Offset(0f, overscrollConfig.drawPadding.calculateTopPadding().toPx()) + val offset = Offset(0f, glowDrawPadding.calculateTopPadding().toPx()) return drawWithRotationAndOffset( rotationDegrees = 0f, offset = offset, @@ -391,7 +472,7 @@ private class DrawGlowOverscrollModifier( private fun DrawScope.drawRightGlow(right: EdgeEffect, canvas: NativeCanvas): Boolean { val width = size.width.roundToInt() - val rightPadding = overscrollConfig.drawPadding.calculateRightPadding(layoutDirection) + val rightPadding = glowDrawPadding.calculateRightPadding(layoutDirection) val offset = Offset(0f, -width.toFloat() + rightPadding.toPx()) return drawWithRotationAndOffset( rotationDegrees = 90f, @@ -402,7 +483,7 @@ private class DrawGlowOverscrollModifier( } private fun DrawScope.drawBottomGlow(bottom: EdgeEffect, canvas: NativeCanvas): Boolean { - val bottomPadding = overscrollConfig.drawPadding.calculateBottomPadding().toPx() + val bottomPadding = glowDrawPadding.calculateBottomPadding().toPx() val offset = Offset(-size.width, -size.height + bottomPadding) return drawWithRotationAndOffset( rotationDegrees = 180f, @@ -430,12 +511,12 @@ private class DrawGlowOverscrollModifier( internal class AndroidEdgeEffectOverscrollEffect( context: Context, private val density: Density, - overscrollConfig: OverscrollConfiguration + glowColor: Color, + glowDrawPadding: PaddingValues ) : OverscrollEffect { private var pointerPosition: Offset = Offset.Unspecified - private val edgeEffectWrapper = - EdgeEffectWrapper(context, glowColor = overscrollConfig.glowColor.toArgb()) + private val edgeEffectWrapper = EdgeEffectWrapper(context, glowColor = glowColor.toArgb()) internal val redrawSignal = mutableStateOf(Unit, neverEqualPolicy()) @@ -690,55 +771,49 @@ internal class AndroidEdgeEffectOverscrollEffect( return Offset(x, y) } - override val effectModifier: Modifier = - Modifier.pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - pointerId = down.id - pointerPosition = down.position - do { - val pressedChanges = awaitPointerEvent().changes.fastFilter { it.pressed } - // If the same ID we are already tracking is down, use that. Otherwise, use - // the next down, to move the overscroll to the next pointer. - val change = - pressedChanges.fastFirstOrNull { it.id == pointerId } - ?: pressedChanges.firstOrNull() - if (change != null) { - // Update the id if we are now tracking a new down - pointerId = change.id - pointerPosition = change.position - } - } while (pressedChanges.isNotEmpty()) - pointerId = PointerId(-1L) - // Explicitly not resetting the pointer position until the next down, so we - // don't change any existing effects - } - } - .then( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - DrawStretchOverscrollModifier( - this@AndroidEdgeEffectOverscrollEffect, - edgeEffectWrapper, - debugInspectorInfo { - name = "overscroll" - value = this@AndroidEdgeEffectOverscrollEffect - } - ) - } else { - DrawGlowOverscrollModifier( - this@AndroidEdgeEffectOverscrollEffect, - edgeEffectWrapper, - overscrollConfig, - debugInspectorInfo { - name = "overscroll" - value = this@AndroidEdgeEffectOverscrollEffect - } - ) + private val pointerInputNode = SuspendingPointerInputModifierNode { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + pointerId = down.id + pointerPosition = down.position + do { + val pressedChanges = awaitPointerEvent().changes.fastFilter { it.pressed } + // If the same ID we are already tracking is down, use that. Otherwise, use + // the next down, to move the overscroll to the next pointer. + val change = + pressedChanges.fastFirstOrNull { it.id == pointerId } + ?: pressedChanges.firstOrNull() + if (change != null) { + // Update the id if we are now tracking a new down + pointerId = change.id + pointerPosition = change.position } + } while (pressedChanges.isNotEmpty()) + pointerId = PointerId(-1L) + // Explicitly not resetting the pointer position until the next down, so we + // don't change any existing effects + } + } + + override val node = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + StretchOverscrollNode( + pointerInputNode, + this@AndroidEdgeEffectOverscrollEffect, + edgeEffectWrapper, + ) + } else { + GlowOverscrollNode( + pointerInputNode, + this@AndroidEdgeEffectOverscrollEffect, + edgeEffectWrapper, + glowDrawPadding ) + } internal fun invalidateOverscroll() { if (invalidationEnabled) { + // TODO: b/367437728 replace with invalidateDraw() redrawSignal.value = Unit } } @@ -972,3 +1047,7 @@ private fun destretchMultiplier(source: NestedScrollSource): Float = * happen at first and then when the stretch disappears, the content starts scrolling quickly. */ private const val FlingDestretchFactor = 4f + +/** From [EdgeEffect] defaults */ +private val DefaultGlowColor = Color(0xff666666) +private val DefaultGlowPaddingValues = PaddingValues() diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Clickable.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Clickable.android.kt index 1eb602ad7cbed..14bbe2be7bfc3 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Clickable.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Clickable.android.kt @@ -16,19 +16,9 @@ package androidx.compose.foundation -import android.view.KeyEvent.KEYCODE_DPAD_CENTER -import android.view.KeyEvent.KEYCODE_ENTER -import android.view.KeyEvent.KEYCODE_NUMPAD_ENTER -import android.view.KeyEvent.KEYCODE_SPACE import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.KeyEventType.Companion.KeyDown -import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.nativeKeyCode -import androidx.compose.ui.input.key.type import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.requireView @@ -48,27 +38,3 @@ private fun View.isInScrollableViewGroup(): Boolean { } internal actual val TapIndicationDelay: Long = ViewConfiguration.getTapTimeout().toLong() - -/** - * Whether the specified [KeyEvent] should trigger a press for a clickable component, i.e. whether - * it is associated with a press of an enter key or dpad centre. - */ -internal actual val KeyEvent.isPress: Boolean - get() = type == KeyDown && isEnter - -/** - * Whether the specified [KeyEvent] should trigger a click for a clickable component, i.e. whether - * it is associated with a release of an enter key or dpad centre. - */ -internal actual val KeyEvent.isClick: Boolean - get() = type == KeyUp && isEnter - -private val KeyEvent.isEnter: Boolean - get() = - when (key.nativeKeyCode) { - KEYCODE_DPAD_CENTER, - KEYCODE_ENTER, - KEYCODE_NUMPAD_ENTER, - KEYCODE_SPACE -> true - else -> false - } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt index ac5cdf41c0a6f..a99fecf98caa5 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DEPRECATION") + package androidx.compose.foundation import androidx.compose.foundation.layout.PaddingValues @@ -29,6 +31,10 @@ import androidx.compose.ui.graphics.Color * @param drawPadding the amount of padding to apply from scrollable container bounds to the effect * before drawing it, if the platform effect is a glow effect, otherwise ignored. */ +@Deprecated( + "Providing `OverscrollConfiguration` through `LocalOverscrollConfiguration` to disable / configure overscroll has been replaced with `LocalOverscrollFactory` and `rememberPlatformOverscrollFactory`. To disable overscroll, instead of `LocalOverscrollConfiguration provides null`, use `LocalOverscrollFactory provides null`. To change the glow color / padding, instead of `LocalOverscrollConfiguration provides OverscrollConfiguration(myColor, myPadding)`, use `LocalOverscrollFactory provides rememberPlatformOverscrollFactory(myColor, myPadding)`" +) +@ExperimentalFoundationApi @Stable class OverscrollConfiguration( val glowColor: Color = Color(0xff666666), // taken from EdgeEffect.java defaults @@ -61,6 +67,13 @@ class OverscrollConfiguration( * Composition local to provide configuration for scrolling containers down the hierarchy. `null` * means there will be no overscroll at all. */ +@Deprecated( + "Providing `OverscrollConfiguration` through `LocalOverscrollConfiguration` to disable / configure overscroll has been replaced with `LocalOverscrollFactory` and `rememberPlatformOverscrollFactory`. To disable overscroll, instead of `LocalOverscrollConfiguration provides null`, use `LocalOverscrollFactory provides null`. To change the glow color / padding, instead of `LocalOverscrollConfiguration provides OverscrollConfiguration(myColor, myPadding)`, use `LocalOverscrollFactory provides rememberPlatformOverscrollFactory(myColor, myPadding)`", + replaceWith = + ReplaceWith("LocalOverscrollFactory", "androidx.compose.foundation.LocalOverscrollFactory") +) @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") +@ExperimentalFoundationApi +@get:ExperimentalFoundationApi val LocalOverscrollConfiguration = compositionLocalOf { OverscrollConfiguration() } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt new file mode 100644 index 0000000000000..14c01304fa0a8 --- /dev/null +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt @@ -0,0 +1,38 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text + +import androidx.compose.ui.graphics.Color + +/** + * Represents the colors used for text selection by text and text field components. + * + * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy. + * + * @property autofillHighlightColor the color used to draw the background behind autofilled + * elements. + */ +@JvmInline +actual value class AutofillHighlight actual constructor(actual val autofillHighlightColor: Color) { + actual companion object { + /** Default color used is framework's "autofilled_highlight" color. */ + private val DefaultAutofillColor = Color(0x4dffeb3b) + + /** Default instance of [AutofillHighlight]. */ + actual val Default = AutofillHighlight(DefaultAutofillColor) + } +} diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt index afda250ecf312..5eb18f5e2e980 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt @@ -16,6 +16,8 @@ package androidx.compose.foundation.text +import android.os.Build +import androidx.compose.foundation.R import androidx.compose.foundation.contextmenu.ContextMenuScope import androidx.compose.foundation.contextmenu.ContextMenuState import androidx.compose.foundation.contextmenu.close @@ -80,7 +82,14 @@ internal enum class TextContextMenuItems(private val stringId: Int) { Cut(android.R.string.cut), Copy(android.R.string.copy), Paste(android.R.string.paste), - SelectAll(android.R.string.selectAll); + SelectAll(android.R.string.selectAll), + Autofill( + if (Build.VERSION.SDK_INT <= 26) { + R.string.autofill + } else { + android.R.string.autofill + } + ); @ReadOnlyComposable @Composable fun resolvedString(): String = stringResource(stringId) } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.android.kt index e1bf5ed2b0730..c1670f0969b9c 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextFieldScroll.android.kt @@ -16,17 +16,10 @@ package androidx.compose.foundation.text -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.OverscrollEffect -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation -@ExperimentalFoundationApi -@Composable -internal actual fun rememberTextFieldOverscrollEffect(): OverscrollEffect? = null - internal actual fun Modifier.textFieldScroll( scrollerPosition: TextFieldScrollerPosition, textFieldValue: TextFieldValue, diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.android.kt index 7080f26135839..5ff6f9779c071 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.android.kt @@ -19,3 +19,5 @@ package androidx.compose.foundation.text import androidx.compose.ui.input.pointer.PointerIcon internal actual val textPointerIcon: PointerIcon = PointerIcon(android.view.PointerIcon.TYPE_TEXT) +internal actual val handwritingPointerIcon: PointerIcon = + PointerIcon(android.view.PointerIcon.TYPE_HANDWRITING) diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt index 1c3bf10dadf60..52b1884421922 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt @@ -17,14 +17,16 @@ package androidx.compose.foundation.text.handwriting import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.handwritingPointerIcon import androidx.compose.foundation.text.input.internal.ComposeInputMethodManager import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.stylusHoverIcon import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.TouchBoundsExpansion import androidx.compose.ui.node.requireView import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.unit.IntSize @@ -53,11 +55,8 @@ import androidx.compose.ui.unit.IntSize */ fun Modifier.handwritingDetector(callback: () -> Unit) = if (isStylusHandwritingSupported) { - then(HandwritingDetectorElement(callback)) - .padding( - horizontal = HandwritingBoundsHorizontalOffset, - vertical = HandwritingBoundsVerticalOffset - ) + this.stylusHoverIcon(handwritingPointerIcon, false, HandwritingBoundsExpansion) + .then(HandwritingDetectorElement(callback)) } else { this } @@ -100,10 +99,12 @@ private class HandwritingDetectorNode(var callback: () -> Unit) : val pointerInputNode = delegate( - StylusHandwritingNodeWithNegativePadding { + StylusHandwritingNode { callback() composeImm.prepareStylusHandwritingDelegation() - return@StylusHandwritingNodeWithNegativePadding true } ) + + override val touchBoundsExpansion: TouchBoundsExpansion + get() = pointerInputNode.touchBoundsExpansion } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSession.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSession.android.kt index 9fade05e260f5..4535283b6b719 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSession.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/AndroidTextInputSession.android.kt @@ -29,7 +29,6 @@ import androidx.annotation.VisibleForTesting import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.content.TransferableContent import androidx.compose.foundation.content.internal.ReceiveContentConfiguration -import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.internal.HandwritingGestureApi34.performHandwritingGesture import androidx.compose.foundation.text.input.internal.HandwritingGestureApi34.previewHandwritingGesture @@ -53,6 +52,7 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe imeOptions: ImeOptions, receiveContentConfiguration: ReceiveContentConfiguration?, onImeAction: ((ImeAction) -> Unit)?, + updateSelectionState: (() -> Unit)?, stylusHandwritingTrigger: MutableSharedFlow?, viewConfiguration: ViewConfiguration? ): Nothing { @@ -62,6 +62,7 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe imeOptions = imeOptions, receiveContentConfiguration = receiveContentConfiguration, onImeAction = onImeAction, + updateSelectionState = updateSelectionState, composeImm = ComposeInputMethodManager(view), stylusHandwritingTrigger = stylusHandwritingTrigger, viewConfiguration = viewConfiguration @@ -75,28 +76,22 @@ internal suspend fun PlatformTextInputSession.platformSpecificTextInputSession( imeOptions: ImeOptions, receiveContentConfiguration: ReceiveContentConfiguration?, onImeAction: ((ImeAction) -> Unit)?, + updateSelectionState: (() -> Unit)?, composeImm: ComposeInputMethodManager, stylusHandwritingTrigger: MutableSharedFlow?, viewConfiguration: ViewConfiguration? ): Nothing { coroutineScope { launch(start = CoroutineStart.UNDISPATCHED) { - state.collectImeNotifications { oldValue, newValue, restartImeIfContentChanges -> + state.collectImeNotifications { oldValue, newValue, restartIme -> val oldSelection = oldValue.selection - val newSelection = newValue.selection val oldComposition = oldValue.composition + val newSelection = newValue.selection val newComposition = newValue.composition - // No need to restart the IME if there wasn't a composing region. This is useful - // to not unnecessarily restart filtered digit only, or password fields. - if ( - restartImeIfContentChanges && - oldValue.composition != null && - !oldValue.contentEquals(newValue) - ) { + if (restartIme) { composeImm.restartInput() } else if (oldSelection != newSelection || oldComposition != newComposition) { - // Don't call updateSelection if input is going to be restarted anyway composeImm.updateSelection( selectionStart = newSelection.min, selectionEnd = newSelection.max, @@ -129,18 +124,12 @@ internal suspend fun PlatformTextInputSession.platformSpecificTextInputSession( startInputMethod { outAttrs -> logDebug { "createInputConnection(value=\"${state.visualText}\")" } + val imeEditCommandScope = DefaultImeEditCommandScope(state) val textInputSession = - object : TextInputSession { + object : TextInputSession, ImeEditCommandScope by imeEditCommandScope { override val text: TextFieldCharSequence get() = state.visualText - override fun requestEdit(block: TextFieldBuffer.() -> Unit) { - state.editUntransformedTextAsUser( - restartImeIfContentChanges = false, - block = block - ) - } - override fun sendKeyEvent(keyEvent: KeyEvent) { composeImm.sendKeyEvent(keyEvent) } @@ -165,6 +154,7 @@ internal suspend fun PlatformTextInputSession.platformSpecificTextInputSession( return state.performHandwritingGesture( gesture, layoutState, + updateSelectionState, viewConfiguration ) } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/CursorAnchorInfoController.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/CursorAnchorInfoController.android.kt index b183166f81aa4..52f2c366ebf94 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/CursorAnchorInfoController.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/CursorAnchorInfoController.android.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.setFrom -import androidx.compose.ui.layout.transformToScreen import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt index 3653f27cf6176..82c5dd168c28f 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt @@ -57,12 +57,15 @@ internal object HandwritingGestureApi34 { internal fun TransformedTextFieldState.performHandwritingGesture( handwritingGesture: HandwritingGesture, layoutState: TextLayoutState, + updateSelectionState: (() -> Unit)?, viewConfiguration: ViewConfiguration? ): Int { return when (handwritingGesture) { - is SelectGesture -> performSelectGesture(handwritingGesture, layoutState) + is SelectGesture -> + performSelectGesture(handwritingGesture, layoutState, updateSelectionState) is DeleteGesture -> performDeleteGesture(handwritingGesture, layoutState) - is SelectRangeGesture -> performSelectRangeGesture(handwritingGesture, layoutState) + is SelectRangeGesture -> + performSelectRangeGesture(handwritingGesture, layoutState, updateSelectionState) is DeleteRangeGesture -> performDeleteRangeGesture(handwritingGesture, layoutState) is JoinOrSplitGesture -> performJoinOrSplitGesture(handwritingGesture, layoutState, viewConfiguration) @@ -92,7 +95,8 @@ internal object HandwritingGestureApi34 { private fun TransformedTextFieldState.performSelectGesture( gesture: SelectGesture, - layoutState: TextLayoutState + layoutState: TextLayoutState, + updateSelectionState: (() -> Unit)?, ): Int { val rangeInTransformedText = layoutState @@ -103,8 +107,8 @@ internal object HandwritingGestureApi34 { ) .apply { if (collapsed) return fallback(gesture) } - // TODO(332749926) show toolbar after selection. selectCharsIn(rangeInTransformedText) + updateSelectionState?.invoke() return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS } @@ -159,7 +163,8 @@ internal object HandwritingGestureApi34 { private fun TransformedTextFieldState.performSelectRangeGesture( gesture: SelectRangeGesture, - layoutState: TextLayoutState + layoutState: TextLayoutState, + updateSelectionState: (() -> Unit)?, ): Int { val rangeInTransformedText = layoutState @@ -171,8 +176,8 @@ internal object HandwritingGestureApi34 { ) .apply { if (collapsed) return fallback(gesture) } - // TODO(332749926) show toolbar after selection. selectCharsIn(rangeInTransformedText) + updateSelectionState?.invoke() return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/EditCommand.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt similarity index 69% rename from compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/EditCommand.kt rename to compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt index 348323657b4f0..864648ab30303 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/EditCommand.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommand.android.kt @@ -18,12 +18,114 @@ package androidx.compose.foundation.text.input.internal import androidx.annotation.VisibleForTesting import androidx.compose.foundation.internal.requirePrecondition -import androidx.compose.foundation.text.findPrecedingBreak import androidx.compose.foundation.text.input.PlacedAnnotation import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.adjustTextRange import androidx.compose.foundation.text.input.delete +import androidx.compose.runtime.collection.mutableVectorOf import androidx.compose.ui.text.TextRange +import androidx.compose.ui.util.fastCoerceIn + +/** + * IME ideally knows about the visual/presented text and not the raw state of [TextFieldState], so + * the edit commands are sent according to what's seen by the user. This scope enables edit appliers + * to convert text ranges from transformed space to original space before applying them. It is sort + * of a reverse output transformation. + * + * This scope also carries batch editing capabilities so any edit op called on this scope can be + * wrapped in a batch for various purposes. + */ +internal interface ImeEditCommandScope { + /** + * Transforms given [range] from transformed space to original space. Please note that this + * function runs in the current TextFieldState. This means that the ongoing edits are not + * visible to this transform function yet. + */ + fun mapFromTransformed(range: TextRange): TextRange + + /** + * Transforms given [range] from original space to transformed space. Please note that this + * function runs in the current TextFieldState. This means that the ongoing edits are not + * visible to this transform function yet. + */ + fun mapToTransformed(range: TextRange): TextRange + + /** + * Start a batch edit. All [edit] calls coming after [beginBatchEdit] are only executed after + * corresponding [endBatchEdit] call comes. Returns true if a successful new batch is started. + */ + fun beginBatchEdit(): Boolean + + /** + * Add edit op to internal list with wrapping batch edit. It's not guaranteed by IME that batch + * editing will be used for every operation. Instead, [ImeEditCommandScope] should create its + * own mini batch for every edit op. These batches are only applied when batch depth reaches 0, + * meaning that artificial batches won't be applied until the real batches are completed. + */ + fun edit(block: TextFieldBuffer.() -> Unit) + + /** End an ongoing batch edit. Returns true if there's still a batch ongoing. */ + fun endBatchEdit(): Boolean +} + +internal class DefaultImeEditCommandScope( + private val transformedTextFieldState: TransformedTextFieldState +) : ImeEditCommandScope { + + /** + * The depth of the batch session. 0 means no session. + * + * Sometimes InputConnection does not call begin/endBatchEdit functions before calling other + * edit functions like commitText or setComposingText. StatelessInputConnection starts and + * finishes a new artificial batch for every EditCommand to make sure that there is always an + * ongoing batch. EditCommands are only applied when batchDepth reaches 0. + */ + private var batchDepth: Int = 0 + + /** + * Transforms given [range] from transformed space to original space. Please note that this + * function runs in the current TextFieldState. This means that the ongoing edits are not + * visible to this transform function yet. + */ + override fun mapFromTransformed(range: TextRange) = + transformedTextFieldState.mapFromTransformed(range) + + /** + * Transforms given [range] from original space to transformed space. Please note that this + * function runs in the current TextFieldState. This means that the ongoing edits are not + * visible to this transform function yet. + */ + override fun mapToTransformed(range: TextRange) = + transformedTextFieldState.mapToTransformed(range) + + private val editCommands = mutableVectorOf Unit>() + + override fun beginBatchEdit(): Boolean { + batchDepth++ + return true + } + + override fun edit(block: TextFieldBuffer.() -> Unit) { + beginBatchEdit() + editCommands.add(block) + endBatchEdit() + } + + override fun endBatchEdit(): Boolean { + batchDepth-- + if (batchDepth == 0 && editCommands.isNotEmpty()) { + // apply the changes to active input session in order. + transformedTextFieldState.editUntransformedTextAsUser( + restartImeIfContentChanges = false + ) { + editCommands.forEach { it.invoke(this) } + } + editCommands.clear() + } + return batchDepth > 0 + } +} /** * Commit final [text] to the text box and set the new cursor position. @@ -34,9 +136,9 @@ import androidx.compose.ui.text.TextRange * @param text The text to commit. * @param newCursorPosition The cursor position after inserted text. */ -internal fun TextFieldBuffer.commitText(text: String, newCursorPosition: Int) { - // API description says replace ongoing composition text if there. Then, if there is no - // composition text, insert text into cursor position or replace selection. +internal fun ImeEditCommandScope.commitText(text: String, newCursorPosition: Int) = edit { + // API description says to replace the ongoing composition text if there is any. Then, if + // there is no composition text, insert text into cursor position or replace selection. val compositionRange = composition if (compositionRange != null) { imeReplace(compositionRange.start, compositionRange.end, text) @@ -45,7 +147,7 @@ internal fun TextFieldBuffer.commitText(text: String, newCursorPosition: Int) { imeReplace(selection.start, selection.end, text) } - // After replace function is called, the editing buffer places the cursor at the end of the + // After replace function is called, the buffer places the cursor at the end of the // modified range. val newCursor = selection.start @@ -69,7 +171,7 @@ internal fun TextFieldBuffer.commitText(text: String, newCursorPosition: Int) { * @param start The inclusive start offset of the composing region. * @param end The exclusive end offset of the composing region */ -internal fun TextFieldBuffer.setComposingRegion(start: Int, end: Int) { +internal fun ImeEditCommandScope.setComposingRegion(start: Int, end: Int) = edit { // The API description says, different from SetComposingText, SetComposingRegion must // preserve the ongoing composition text and set new composition. if (hasComposition()) { @@ -100,11 +202,11 @@ internal fun TextFieldBuffer.setComposingRegion(start: Int, end: Int) { * @param annotations Text annotations that IME attaches to the composing region. e.g. background * color or underline styling. */ -internal fun TextFieldBuffer.setComposingText( +internal fun ImeEditCommandScope.setComposingText( text: String, newCursorPosition: Int, annotations: List? = null -) { +) = edit { val compositionRange = composition if (compositionRange != null) { // API doc says, if there is ongoing composing text, replace it with new text. @@ -157,10 +259,10 @@ internal fun TextFieldBuffer.setComposingText( * @param lengthAfterCursor The number of characters in UTF-16 after the cursor to be deleted. Must * be non-negative. */ -internal fun TextFieldBuffer.deleteSurroundingText( +internal fun ImeEditCommandScope.deleteSurroundingText( lengthBeforeCursor: Int, lengthAfterCursor: Int -) { +) = edit { requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " + "$lengthBeforeCursor and $lengthAfterCursor respectively." @@ -191,10 +293,10 @@ internal fun TextFieldBuffer.deleteSurroundingText( * @param lengthAfterCursor The number of characters in Unicode code points after the cursor to be * deleted. Must be non-negative. */ -internal fun TextFieldBuffer.deleteSurroundingTextInCodePoints( +internal fun ImeEditCommandScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor: Int, lengthAfterCursor: Int -) { +) = edit { requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " + "$lengthBeforeCursor and $lengthAfterCursor respectively." @@ -248,43 +350,19 @@ internal fun TextFieldBuffer.deleteSurroundingTextInCodePoints( * See * [`finishComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#finishComposingText()). */ -internal fun TextFieldBuffer.finishComposingText() { - commitComposition() -} +internal fun ImeEditCommandScope.finishComposingText() = edit { commitComposition() } /** - * Represents a backspace operation at the cursor position. - * - * If there is composition, delete the text in the composition range. If there is no composition but - * there is selection, delete whole selected range. If there is no composition and selection, - * perform backspace key event at the cursor position. + * Sets selection while converting the given range from transformed space to original space and + * coercing to the buffer range. */ -internal fun TextFieldBuffer.backspace() { - val compositionRange = composition - if (compositionRange != null) { - imeDelete(compositionRange.start, compositionRange.end) - } else if (hasSelection) { - val delStart = selection.start - val delEnd = selection.end - selection = TextRange(selection.start) - imeDelete(delStart, delEnd) - } else if (selection.collapsed && selection.start > 0) { - val prevCursorPos = toString().findPrecedingBreak(selection.start) - imeDelete(prevCursorPos, selection.start) - } -} +internal fun ImeEditCommandScope.setSelection(start: Int, end: Int) = edit { + // First we need to find the length of transformed space so we can clamp [start] and [end] + val transformedSpaceLength = mapToTransformed(TextRange(0, length)) -/** Deletes all the text in the buffer. */ -internal fun TextFieldBuffer.deleteAll() { - imeReplace(0, length, "") -} - -/** Sets selection while coercing the given parameters to the buffer range. */ -internal fun TextFieldBuffer.setSelection(start: Int, end: Int) { - val clampedStart = start.coerceIn(0, length) - val clampedEnd = end.coerceIn(0, length) - - selection = TextRange(clampedStart, clampedEnd) + val clampedStart = start.fastCoerceIn(transformedSpaceLength.min, transformedSpaceLength.max) + val clampedEnd = end.fastCoerceIn(transformedSpaceLength.min, transformedSpaceLength.max) + selection = mapFromTransformed(TextRange(clampedStart, clampedEnd)) } /** @@ -346,6 +424,10 @@ internal fun TextFieldBuffer.imeReplace(start: Int, end: Int, text: CharSequence if (cMin != cMax || i != j) { replace(start = cMin, end = cMax, text = text.subSequence(i, j)) + } else { + // We still need to clear the current state since this is essentially a replace call. + commitComposition() + clearHighlight() } // IME replace calls should always place the selection at the end of replaced region. diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyCursorAnchorInfoController.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyCursorAnchorInfoController.android.kt index c2101513f8de0..0f459bd9b96a5 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyCursorAnchorInfoController.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyCursorAnchorInfoController.android.kt @@ -135,7 +135,15 @@ internal class LegacyCursorAnchorInfoController( } private fun updateCursorAnchorInfo() { - if (!inputMethodManager.isActive()) return + if ( + !inputMethodManager.isActive() || + textFieldValue == null || + offsetMapping == null || + textLayoutResult == null || + innerTextFieldBounds == null || + decorationBoxBounds == null + ) + return matrix.reset() // Updates matrix to transform decoration box coordinates to screen coordinates. diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.android.kt index 03de1b01ba42e..d53c83cc3a9e2 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.android.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.text.selection.TextFieldSelectionManager import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.layout.transformToScreen import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.text.TextLayoutResult diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnection.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnection.android.kt index 67d8df388f3ed..330b98df6fee0 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnection.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/StatelessInputConnection.android.kt @@ -49,7 +49,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.content.PlatformTransferableContent import androidx.compose.foundation.content.TransferableContent import androidx.compose.foundation.text.input.PlacedAnnotation -import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.getSelectedText import androidx.compose.foundation.text.input.getTextAfterSelection @@ -110,7 +109,7 @@ internal class StatelessInputConnection( get() = session.text /** Recording of editing operations for batch editing */ - private val editCommands = mutableVectorOf Unit>() + private val editCommands = mutableVectorOf Unit>() /** * Wraps this StatelessInputConnection to halt a possible infinite loop in [commitContent] @@ -206,46 +205,20 @@ internal class StatelessInputConnection( } ) - /** - * Add edit op to internal list with wrapping batch edit. It's not guaranteed by IME that batch - * editing will be used for every operation. Instead, [StatelessInputConnection] creates its own - * mini batches for every edit op. These batches are only applied when batch depth reaches 0, - * meaning that artificial batches won't be applied until the real batches are completed. - */ - private fun addEditCommandWithBatch(editCommand: TextFieldBuffer.() -> Unit) { - beginBatchEditInternal() - try { - editCommands.add(editCommand) - } finally { - endBatchEditInternal() - } - } - // region Methods for batch editing and session control override fun beginBatchEdit(): Boolean { logDebug("beginBatchEdit()") return beginBatchEditInternal() } - private fun beginBatchEditInternal(): Boolean { - batchDepth++ - return true - } + private fun beginBatchEditInternal() = session.beginBatchEdit() override fun endBatchEdit(): Boolean { logDebug("endBatchEdit()") return endBatchEditInternal() } - private fun endBatchEditInternal(): Boolean { - batchDepth-- - if (batchDepth == 0 && editCommands.isNotEmpty()) { - // apply the changes to active input session in order. - session.requestEdit { editCommands.forEach { it.invoke(this) } } - editCommands.clear() - } - return batchDepth > 0 - } + private fun endBatchEditInternal() = session.endBatchEdit() override fun closeConnection() { logDebug("closeConnection()") @@ -260,50 +233,48 @@ internal class StatelessInputConnection( override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { logDebug("commitText(\"$text\", $newCursorPosition)") if (text == null) return true - addEditCommandWithBatch { commitText(text.toString(), newCursorPosition) } + session.commitText(text.toString(), newCursorPosition) return true } override fun setComposingRegion(start: Int, end: Int): Boolean { logDebug("setComposingRegion($start, $end)") - addEditCommandWithBatch { setComposingRegion(start, end) } + session.setComposingRegion(start, end) return true } override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean { logDebug("setComposingText(\"$text\", $newCursorPosition)") if (text == null) return true - addEditCommandWithBatch { - this@addEditCommandWithBatch.setComposingText( - text = text.toString(), - newCursorPosition = newCursorPosition, - annotations = (text as? Spanned)?.toAnnotationList() - ) - } + session.setComposingText( + text = text.toString(), + newCursorPosition = newCursorPosition, + annotations = (text as? Spanned)?.toAnnotationList() + ) return true } override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean { logDebug("deleteSurroundingTextInCodePoints($beforeLength, $afterLength)") - addEditCommandWithBatch { deleteSurroundingTextInCodePoints(beforeLength, afterLength) } + session.deleteSurroundingTextInCodePoints(beforeLength, afterLength) return true } override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { logDebug("deleteSurroundingText($beforeLength, $afterLength)") - addEditCommandWithBatch { deleteSurroundingText(beforeLength, afterLength) } + session.deleteSurroundingText(beforeLength, afterLength) return true } override fun setSelection(start: Int, end: Int): Boolean { logDebug("setSelection($start, $end)") - addEditCommandWithBatch { this@addEditCommandWithBatch.setSelection(start, end) } + session.setSelection(start, end) return true } override fun finishComposingText(): Boolean { logDebug("finishComposingText()") - addEditCommandWithBatch { finishComposingText() } + session.finishComposingText() return true } @@ -407,11 +378,7 @@ internal class StatelessInputConnection( override fun performContextMenuAction(id: Int): Boolean { logDebug("performContextMenuAction($id)") when (id) { - android.R.id.selectAll -> { - addEditCommandWithBatch { - this@addEditCommandWithBatch.setSelection(0, text.length) - } - } + android.R.id.selectAll -> session.setSelection(0, text.length) // TODO(siyamed): Need proper connection to cut/copy/paste android.R.id.cut -> sendSynthesizedKeyEvent(KeyEvent.KEYCODE_CUT) android.R.id.copy -> sendSynthesizedKeyEvent(KeyEvent.KEYCODE_COPY) diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.android.kt index 446eaa8a05f2e..148e87458190a 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.android.kt @@ -23,7 +23,6 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.PreviewableHandwritingGesture import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.content.TransferableContent -import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.ImeAction @@ -34,7 +33,7 @@ import androidx.compose.ui.text.input.TextFieldValue * [StatelessInputConnection] from [TextFieldState] for testability. */ @OptIn(ExperimentalFoundationApi::class) -internal interface TextInputSession { +internal interface TextInputSession : ImeEditCommandScope { /** * The current [TextFieldValue] in this input session. This value is typically supplied by a @@ -42,13 +41,6 @@ internal interface TextInputSession { */ val text: TextFieldCharSequence - /** - * Callback to execute for InputConnection to communicate the changes requested by the IME. - * - * @param block Lambda scoped to an EditingBuffer to apply changes direct onto a buffer. - */ - fun requestEdit(block: TextFieldBuffer.() -> Unit) - /** Delegates IME requested KeyEvents. */ fun sendKeyEvent(keyEvent: KeyEvent) diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt index e770393e7745f..d1445cd99600e 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.text.input.internal.selection +import android.os.Build import androidx.compose.foundation.contextmenu.ContextMenuScope import androidx.compose.foundation.contextmenu.ContextMenuState import androidx.compose.foundation.text.TextContextMenuItems @@ -30,4 +31,7 @@ internal fun TextFieldSelectionState.contextMenuBuilder( } TextItem(state, TextContextMenuItems.Paste, enabled = canPaste()) { paste() } TextItem(state, TextContextMenuItems.SelectAll, enabled = canSelectAll()) { selectAll() } + if (Build.VERSION.SDK_INT >= 26) { + TextItem(state, TextContextMenuItems.Autofill, enabled = canAutofill()) { autofill() } + } } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt index dae430c4839b8..759a6feabcac4 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.text.selection +import android.os.Build import androidx.compose.foundation.PlatformMagnifierFactory import androidx.compose.foundation.contextmenu.ContextMenuScope import androidx.compose.foundation.contextmenu.ContextMenuState @@ -104,4 +105,13 @@ internal fun TextFieldSelectionManager.contextMenuBuilder( ) { selectAll() } + if (Build.VERSION.SDK_INT >= 26) { + TextItem( + state = contextMenuState, + label = TextContextMenuItems.Autofill, + enabled = editable && value.selection.collapsed + ) { + autofill() + } + } } diff --git a/compose/foundation/foundation/src/androidMain/res/values/strings.xml b/compose/foundation/foundation/src/androidMain/res/values/strings.xml index cb6255cacfc11..f1a2c47b0ad10 100644 --- a/compose/foundation/foundation/src/androidMain/res/values/strings.xml +++ b/compose/foundation/foundation/src/androidMain/res/values/strings.xml @@ -17,4 +17,6 @@ tooltip show tooltip + + Autofill \ No newline at end of file diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/AutoSizeTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/AutoSizeTest.kt index 7cc941fba2e31..fa56928e1c9aa 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/AutoSizeTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/AutoSizeTest.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.text +import androidx.compose.foundation.text.modifiers.AutoSizeTextLayoutScope import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp @@ -55,96 +56,110 @@ class AutoSizeTest { @Test(expected = IllegalArgumentException::class) fun stepBased_stepSize_tooSmall() { - AutoSize.StepBased(0.00000134.sp) + AutoSize.StepBased(stepSize = 0.00000134.sp) } @Test(expected = IllegalArgumentException::class) fun stepBased_minFontSize_unspecified() { - AutoSize.StepBased(TextUnit.Unspecified, 1.sp) + AutoSize.StepBased(minFontSize = TextUnit.Unspecified, maxFontSize = 1.sp) } @Test(expected = IllegalArgumentException::class) fun stepBased_maxFontSize_unspecified() { - AutoSize.StepBased(2.sp, TextUnit.Unspecified) + AutoSize.StepBased(minFontSize = 2.sp, maxFontSize = TextUnit.Unspecified) } @Test(expected = IllegalArgumentException::class) fun stepBased_stepSize_unspecified() { - AutoSize.StepBased(TextUnit.Unspecified) + AutoSize.StepBased(stepSize = TextUnit.Unspecified) } @Test(expected = IllegalArgumentException::class) fun stepBased_minFontSize_negative() { - AutoSize.StepBased((-1).sp, 0.sp) + AutoSize.StepBased(minFontSize = (-1).sp, maxFontSize = 0.sp) } @Test(expected = IllegalArgumentException::class) fun stepBased_maxFontSize_negative() { - AutoSize.StepBased(0.sp, (-1).sp) + AutoSize.StepBased(minFontSize = 0.sp, maxFontSize = (-1).sp) } @Test fun stepBased_equals() { - var autoSize1 = AutoSize.StepBased(1.sp, 10.sp, 2.sp) - var autoSize2 = AutoSize.StepBased(1.0.sp, 10.0.sp, 2.0.sp) + var autoSize1 = AutoSize.StepBased(minFontSize = 1.sp, maxFontSize = 10.sp, stepSize = 2.sp) + var autoSize2 = + AutoSize.StepBased(minFontSize = 1.0.sp, maxFontSize = 10.0.sp, stepSize = 2.0.sp) assertThat(autoSize1).isEqualTo(autoSize2) assertThat(autoSize2).isEqualTo(autoSize1) - autoSize2 = AutoSize.StepBased(1.1.sp, 10.sp, 2.sp) + autoSize2 = AutoSize.StepBased(minFontSize = 1.1.sp, maxFontSize = 10.sp, stepSize = 2.sp) assertThat(autoSize1).isNotEqualTo(autoSize2) assertThat(autoSize2).isNotEqualTo(autoSize1) - autoSize2 = AutoSize.StepBased(1.sp, 11.1.sp, 2.sp) + autoSize2 = AutoSize.StepBased(minFontSize = 1.sp, maxFontSize = 11.1.sp, stepSize = 2.sp) assertThat(autoSize1).isNotEqualTo(autoSize2) assertThat(autoSize2).isNotEqualTo(autoSize1) - autoSize2 = AutoSize.StepBased(1.sp, 10.sp, 2.5.sp) + autoSize2 = AutoSize.StepBased(minFontSize = 1.sp, maxFontSize = 10.sp, stepSize = 2.5.sp) assertThat(autoSize1).isNotEqualTo(autoSize2) assertThat(autoSize2).isNotEqualTo(autoSize1) autoSize2 = TestAutoSize(7) assertThat(autoSize1).isNotEqualTo(autoSize2) - autoSize1 = AutoSize.StepBased(1.em, 2.em, 0.1.em) - autoSize2 = AutoSize.StepBased(1.0.em, 2.0.em, 0.1.em) + autoSize1 = AutoSize.StepBased(minFontSize = 1.em, maxFontSize = 2.em, stepSize = 0.1.em) + autoSize2 = + AutoSize.StepBased(minFontSize = 1.0.em, maxFontSize = 2.0.em, stepSize = 0.1.em) assertThat(autoSize1).isEqualTo(autoSize2) assertThat(autoSize2).isEqualTo(autoSize1) } @Test fun stepBased_getFontSize_alwaysOverflows() { - val autoSize = AutoSize.StepBased(12.sp, 112.sp, 0.25.sp) - val searchScope: FontSizeSearchScope = AlwaysOverflows() + val autoSize = + AutoSize.StepBased(minFontSize = 12.sp, maxFontSize = 112.sp, stepSize = 0.25.sp) + as TextAutoSize + val searchScope: AutoSizeTextLayoutScope = AlwaysOverflows() with(autoSize) { assertThat(searchScope.getFontSize().value).isEqualTo(12) } } @Test fun stepBased_getFontSize_neverOverflows() { - val autoSize = AutoSize.StepBased(12.sp, 112.sp, 0.25.sp) - val searchScope: FontSizeSearchScope = NeverOverflows() + val autoSize = + AutoSize.StepBased(minFontSize = 12.sp, maxFontSize = 112.sp, stepSize = 0.25.sp) + as TextAutoSize + val searchScope: AutoSizeTextLayoutScope = NeverOverflows() with(autoSize) { assertThat(searchScope.getFontSize().value).isEqualTo(112) } } @Test fun stepBased_getFontSize_cappedAtMaxSize_beforeOverflow() { - val autoSize = AutoSize.StepBased(12.sp, 112.sp, 0.25.sp) - val searchScope: FontSizeSearchScope = OverflowsWhenFontSizeIsGreaterThan60px() + val autoSize = + AutoSize.StepBased(minFontSize = 12.sp, maxFontSize = 112.sp, stepSize = 0.25.sp) + as TextAutoSize + val searchScope: AutoSizeTextLayoutScope = OverflowsWhenFontSizeIsGreaterThan60px() with(autoSize) { assertThat(searchScope.getFontSize().value).isEqualTo(60) } } @Test fun stepBased_getFontSize_searchRangeMidpoint_overflows() { - val autoSize = AutoSize.StepBased(0.sp, 100.sp, 70.sp) - val searchScope: FontSizeSearchScope = OverflowsWhenFontSizeIsGreaterThan60px() + val autoSize = + AutoSize.StepBased(minFontSize = 0.sp, maxFontSize = 100.sp, stepSize = 70.sp) + as TextAutoSize + val searchScope: AutoSizeTextLayoutScope = OverflowsWhenFontSizeIsGreaterThan60px() // Here we're testing when (max - min) / 2 overflows and min doesn't overflow with(autoSize) { assertThat(searchScope.getFontSize().value) }.isEqualTo(0) } @Test fun stepBased_getFontSize_differentStepSizes() { - val autoSize1 = AutoSize.StepBased(10.sp, 100.sp, 10.sp) - val autoSize2 = AutoSize.StepBased(10.sp, 100.sp, 20.sp) - val searchScope: FontSizeSearchScope = OverflowsWhenFontSizeIsGreaterThan60px() + val autoSize1 = + AutoSize.StepBased(minFontSize = 10.sp, maxFontSize = 100.sp, stepSize = 10.sp) + as TextAutoSize + val autoSize2 = + AutoSize.StepBased(minFontSize = 10.sp, maxFontSize = 100.sp, stepSize = 20.sp) + as TextAutoSize + val searchScope: AutoSizeTextLayoutScope = OverflowsWhenFontSizeIsGreaterThan60px() with(autoSize1) { assertThat(searchScope.getFontSize().value).isEqualTo(60) } with(autoSize2) { assertThat(searchScope.getFontSize().value).isEqualTo(50) } @@ -153,9 +168,11 @@ class AutoSizeTest { @Test fun stepBased_getFontSize_stepSize_greaterThan_maxFontSize_minus_minFontSize() { // regardless of the bounds of the container, the only potential font size is minFontSize - val autoSize = AutoSize.StepBased(45.sp, 55.sp, 15.sp) + val autoSize = + AutoSize.StepBased(minFontSize = 45.sp, maxFontSize = 55.sp, stepSize = 15.sp) + as TextAutoSize with(autoSize) { - var searchScope: FontSizeSearchScope = AlwaysOverflows() + var searchScope: AutoSizeTextLayoutScope = AlwaysOverflows() assertThat(searchScope.getFontSize().value).isEqualTo(45) searchScope = NeverOverflows() @@ -166,8 +183,8 @@ class AutoSizeTest { } } - private class TestAutoSize(private val testParam: Int) : AutoSize { - override fun FontSizeSearchScope.getFontSize(): TextUnit { + private class TestAutoSize(private val testParam: Int) : TextAutoSize { + override fun AutoSizeTextLayoutScope.getFontSize(): TextUnit { return if (!performLayoutAndGetOverflow(testParam.sp)) testParam.sp else 3.sp } @@ -183,7 +200,7 @@ class AutoSizeTest { } } - private class AlwaysOverflows : FontSizeSearchScope { + private class AlwaysOverflows : AutoSizeTextLayoutScope { override val density = 1f override val fontScale = 1f @@ -192,7 +209,7 @@ class AutoSizeTest { } } - private class NeverOverflows : FontSizeSearchScope { + private class NeverOverflows : AutoSizeTextLayoutScope { override val density = 1f override val fontScale = 1f @@ -201,7 +218,7 @@ class AutoSizeTest { } } - private class OverflowsWhenFontSizeIsGreaterThan60px : FontSizeSearchScope { + private class OverflowsWhenFontSizeIsGreaterThan60px : AutoSizeTextLayoutScope { override val density = 1f override val fontScale = 1f diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt index 3e286df892901..5d978ae10bdd6 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt @@ -18,6 +18,7 @@ package androidx.compose.foundation.text.input import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text.input.internal.commitText +import androidx.compose.foundation.text.input.internal.withImeScope import androidx.compose.runtime.saveable.SaverScope import androidx.compose.ui.text.TextRange import com.google.common.truth.Truth.assertThat @@ -44,7 +45,7 @@ class TextFieldStateSaverTest { fun savesAndRestoresUndo() { val state = TextFieldState("hello, world", initialSelection = TextRange(0, 5)) - state.editAsUser(null) { commitText("hi", 1) } + state.withImeScope { commitText("hi", 1) } val saved = with(TextFieldState.Saver) { TestSaverScope.save(state) } assertNotNull(saved) diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt index 126777b7de518..45a8bc6fb3d7d 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt @@ -17,7 +17,10 @@ package androidx.compose.foundation.text.input import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.internal.DefaultImeEditCommandScope +import androidx.compose.foundation.text.input.internal.TransformedTextFieldState import androidx.compose.foundation.text.input.internal.setComposingText +import androidx.compose.foundation.text.input.internal.withImeScope import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.SnapshotStateObserver @@ -377,6 +380,39 @@ class TextFieldStateTest { assertThat(state.text.toString()).isEqualTo("hello") } + @Test + fun noOpEdit_doesNot_commitComposition() { + val scope = DefaultImeEditCommandScope(TransformedTextFieldState(state)) + scope.setComposingText("Hello", 1) + + assertThat(state.composition).isEqualTo(TextRange(0, 5)) + + state.edit { /*no-op*/ } + assertThat(state.composition).isEqualTo(TextRange(0, 5)) + } + + @Test + fun editContentChange_commitsComposition() { + val scope = DefaultImeEditCommandScope(TransformedTextFieldState(state)) + scope.setComposingText("Hello", 1) + + assertThat(state.composition).isEqualTo(TextRange(0, 5)) + + state.edit { append(" world") } + assertThat(state.composition).isNull() + } + + @Test + fun editSelectionChange_commitsComposition() { + val scope = DefaultImeEditCommandScope(TransformedTextFieldState(state)) + scope.setComposingText("Hello", 1) + + assertThat(state.composition).isEqualTo(TextRange(0, 5)) + + state.edit { selection = TextRange(3) } + assertThat(state.composition).isNull() + } + @Test fun setTextAndPlaceCursorAtEnd_works() { state.setTextAndPlaceCursorAtEnd("Hello") @@ -665,12 +701,151 @@ class TextFieldStateTest { fun inputTransformationRejectsChanges_removesComposition() { val state = TextFieldState() val inputTransformation = InputTransformation { revertAllChanges() } - state.editAsUser(inputTransformation) { setComposingText("hello", 1) } + state.withImeScope(inputTransformation) { setComposingText("hello", 1) } assertThat(state.text).isEqualTo("") assertThat(state.selection).isEqualTo(TextRange.Zero) assertThat(state.composition).isNull() } + @Test + fun notifyImeListener_firesAfterProgrammaticEdit() { + val state = TextFieldState("Hello") + var oldValueCalled: TextFieldCharSequence? = null + var newValueCalled: TextFieldCharSequence? = null + var restartImeCalled: Boolean? = null + val listener = + TextFieldState.NotifyImeListener { oldValue, newValue, restartIme -> + oldValueCalled = oldValue + newValueCalled = newValue + restartImeCalled = restartIme + } + state.addNotifyImeListener(listener) + + state.edit { append(" World") } + + assertThat(oldValueCalled.toString()).isEqualTo("Hello") + assertThat(newValueCalled.toString()).isEqualTo("Hello World") + assertThat(restartImeCalled).isFalse() + } + + @Test + fun notifyImeListener_firesAfterProgrammaticEdit_restartsImeIfComposing() { + val state = TextFieldState("Hello") + // We need a composing region to fire restartIme + state.editAsUser(null) { setComposition(0, 5) } + var oldValueCalled: TextFieldCharSequence? = null + var newValueCalled: TextFieldCharSequence? = null + var restartImeCalled: Boolean? = null + val listener = + TextFieldState.NotifyImeListener { oldValue, newValue, restartIme -> + oldValueCalled = oldValue + newValueCalled = newValue + restartImeCalled = restartIme + } + state.addNotifyImeListener(listener) + + state.edit { append(" World") } + + assertThat(oldValueCalled.toString()).isEqualTo("Hello") + assertThat(newValueCalled.toString()).isEqualTo("Hello World") + assertThat(restartImeCalled).isTrue() + } + + @Test + fun notifyImeListener_firesAfterProgrammaticEdit_doesNotRestartIfContentIsSame() { + val state = TextFieldState("Hello") + // We need a composing region to fire restartIme + state.editAsUser(null) { setComposition(0, 5) } + var oldValueCalled: TextFieldCharSequence? = null + var newValueCalled: TextFieldCharSequence? = null + var restartImeCalled: Boolean? = null + val listener = + TextFieldState.NotifyImeListener { oldValue, newValue, restartIme -> + oldValueCalled = oldValue + newValueCalled = newValue + restartImeCalled = restartIme + } + state.addNotifyImeListener(listener) + + state.edit { + // this ends up being no-op + append(" World") + delete(5, length) + } + + assertThat(oldValueCalled.toString()).isEqualTo("Hello") + assertThat(newValueCalled.toString()).isEqualTo("Hello") + assertThat(restartImeCalled).isFalse() + } + + @Test + fun notifyImeListener_firesAfterUserEdit() { + val state = TextFieldState("Hello") + // We need a composing region for restartIme to be true. It's not going to be but let's + // cover all corners + state.editAsUser(null) { setComposition(0, 5) } + var oldValueCalled: TextFieldCharSequence? = null + var newValueCalled: TextFieldCharSequence? = null + var restartImeCalled: Boolean? = null + val listener = + TextFieldState.NotifyImeListener { oldValue, newValue, restartIme -> + oldValueCalled = oldValue + newValueCalled = newValue + restartImeCalled = restartIme + } + state.addNotifyImeListener(listener) + + DefaultImeEditCommandScope(TransformedTextFieldState(state)).setComposingText("World", 1) + + assertThat(oldValueCalled.toString()).isEqualTo("Hello") + assertThat(newValueCalled.toString()).isEqualTo("World") + // Even though content changes and there was a composing region, IME is not restarted + assertThat(restartImeCalled).isFalse() + } + + @Test + fun notifyImeListener_firesAfterUndoRedo() { + val state = TextFieldState("Hello") + state.editAsUser(null) { append(" World") } + var oldValueCalled: TextFieldCharSequence? = null + var newValueCalled: TextFieldCharSequence? = null + var restartImeCalled: Boolean? = null + val listener = + TextFieldState.NotifyImeListener { oldValue, newValue, restartIme -> + oldValueCalled = oldValue + newValueCalled = newValue + restartImeCalled = restartIme + } + state.addNotifyImeListener(listener) + + state.undoState.undo() // should remove " World" + + assertThat(oldValueCalled.toString()).isEqualTo("Hello World") + assertThat(newValueCalled.toString()).isEqualTo("Hello") + assertThat(restartImeCalled).isFalse() + } + + @Test + fun notifyImeListener_restartImeIsFalse_ifOnlySelectionIsChanged() { + val state = TextFieldState("Hello", TextRange(3)) + var oldValueCalled: TextFieldCharSequence? = null + var newValueCalled: TextFieldCharSequence? = null + var restartImeCalled: Boolean? = null + val listener = + TextFieldState.NotifyImeListener { oldValue, newValue, restartIme -> + oldValueCalled = oldValue + newValueCalled = newValue + restartImeCalled = restartIme + } + state.addNotifyImeListener(listener) + + state.editAsUser(null, restartImeIfContentChanges = true) { selection = TextRange.Zero } + + assertThat(oldValueCalled?.selection).isEqualTo(TextRange(3)) + assertThat(newValueCalled?.selection).isEqualTo(TextRange(0)) + assertThat(restartImeCalled).isFalse() + } + private fun runTestWithSnapshotsThenCancelChildren(testBody: suspend TestScope.() -> Unit) { val globalWriteObserverHandle = Snapshot.registerGlobalWriteObserver { diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/CommitTextCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/CommitTextCommandTest.kt index e2d8ca2653152..b02bd553a85ad 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/CommitTextCommandTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/CommitTextCommandTest.kt @@ -23,177 +23,188 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) -class CommitTextCommandTest { +internal class CommitTextCommandTest : ImeEditCommandTest() { @Test fun test_insert_empty() { - val eb = TextFieldBuffer("", TextRange.Zero) + imeScope.commitText("X", 1) - eb.commitText("X", 1) - - assertThat(eb.toString()).isEqualTo("X") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("X") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_insert_cursor_tail() { - val eb = TextFieldBuffer("A", TextRange(1)) + initialize("A", TextRange(1)) - eb.commitText("X", 1) + imeScope.commitText("X", 1) - assertThat(eb.toString()).isEqualTo("AX") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("AX") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_insert_cursor_head() { - val eb = TextFieldBuffer("A", TextRange(1)) + initialize("A", TextRange(1)) - eb.commitText("X", 0) + imeScope.commitText("X", 0) - assertThat(eb.toString()).isEqualTo("AX") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("AX") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_insert_cursor_far_tail() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.commitText("X", 2) + imeScope.commitText("X", 2) - assertThat(eb.toString()).isEqualTo("AXBCDE") - assertThat(eb.selection.start).isEqualTo(3) - assertThat(eb.selection.end).isEqualTo(3) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("AXBCDE") + assertThat(state.selection.start).isEqualTo(3) + assertThat(state.selection.end).isEqualTo(3) + assertThat(state.composition).isNull() } @Test fun test_insert_cursor_far_head() { - val eb = TextFieldBuffer("ABCDE", TextRange(4)) + initialize("ABCDE", TextRange(4)) - eb.commitText("X", -2) + imeScope.commitText("X", -2) - assertThat(eb.toString()).isEqualTo("ABCDXE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDXE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_insert_empty_text_cursor_head() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.commitText("", 0) + imeScope.commitText("", 0) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_insert_empty_text_cursor_tail() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.commitText("", 1) + imeScope.commitText("", 1) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_insert_empty_text_cursor_far_tail() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.commitText("", 2) + imeScope.commitText("", 2) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_insert_empty_text_cursor_far_head() { - val eb = TextFieldBuffer("ABCDE", TextRange(4)) + initialize("ABCDE", TextRange(4)) - eb.commitText("", -2) + imeScope.commitText("", -2) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_cancel_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposition(1, 4) // Mark "BCD" as composition - eb.commitText("X", 1) + imeScope.setComposingRegion(1, 4) // Mark "BCD" as composition + imeScope.commitText("X", 1) - assertThat(eb.toString()).isEqualTo("AXE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("AXE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_replace_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(1, 4)) // select "BCD" + initialize("ABCDE", TextRange(1, 4)) // select "BCD" - eb.commitText("X", 1) + imeScope.commitText("X", 1) - assertThat(eb.toString()).isEqualTo("AXE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("AXE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_composition_and_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(1, 3)) // select "BC" + initialize("ABCDE", TextRange(1, 3)) // select "BC" - eb.setComposition(2, 4) // Mark "CD" as composition - eb.commitText("X", 1) + imeScope.setComposingRegion(2, 4) // Mark "CD" as composition + imeScope.commitText("X", 1) // If composition and selection exists at the same time, replace composition and cancel // selection and place cursor. - assertThat(eb.toString()).isEqualTo("ABXE") - assertThat(eb.selection.start).isEqualTo(3) - assertThat(eb.selection.end).isEqualTo(3) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABXE") + assertThat(state.selection.start).isEqualTo(3) + assertThat(state.selection.end).isEqualTo(3) + assertThat(state.composition).isNull() } @Test fun test_cursor_position_too_small() { - val eb = TextFieldBuffer("ABCDE", TextRange(5)) + initialize("ABCDE", TextRange(5)) - eb.commitText("X", -1000) + imeScope.commitText("X", -1000) - assertThat(eb.toString()).isEqualTo("ABCDEX") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDEX") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_cursor_position_too_large() { - val eb = TextFieldBuffer("ABCDE", TextRange(5)) + initialize("ABCDE", TextRange(5)) + + imeScope.commitText("X", 1000) + + assertThat(state.text.toString()).isEqualTo("ABCDEX") + assertThat(state.selection.start).isEqualTo(6) + assertThat(state.selection.end).isEqualTo(6) + assertThat(state.composition).isNull() + } + + @Test + fun committed_text_same_as_current_composition() { + initialize("ABCDE", TextRange(5)) - eb.commitText("X", 1000) + imeScope.setComposingRegion(0, 5) + imeScope.commitText("ABCDE", 1) - assertThat(eb.toString()).isEqualTo("ABCDEX") - assertThat(eb.selection.start).isEqualTo(6) - assertThat(eb.selection.end).isEqualTo(6) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(5) + assertThat(state.selection.end).isEqualTo(5) + assertThat(state.composition).isNull() } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextCommandTest.kt index 193dcc26f97bc..5e82246d1f351 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextCommandTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextCommandTest.kt @@ -24,233 +24,233 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) -class DeleteSurroundingTextCommandTest { +internal class DeleteSurroundingTextCommandTest : ImeEditCommandTest() { @Test fun test_delete_after() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.deleteSurroundingText(0, 1) + imeScope.deleteSurroundingText(0, 1) - assertThat(eb.toString()).isEqualTo("ACDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ACDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_delete_before() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.deleteSurroundingText(1, 0) + imeScope.deleteSurroundingText(1, 0) - assertThat(eb.toString()).isEqualTo("BCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("BCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_delete_both() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_delete_after_multiple() { - val eb = TextFieldBuffer("ABCDE", TextRange(2)) + initialize("ABCDE", TextRange(2)) - eb.deleteSurroundingText(0, 2) + imeScope.deleteSurroundingText(0, 2) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_delete_before_multiple() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.deleteSurroundingText(2, 0) + imeScope.deleteSurroundingText(2, 0) - assertThat(eb.toString()).isEqualTo("ADE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ADE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_delete_both_multiple() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.deleteSurroundingText(2, 2) + imeScope.deleteSurroundingText(2, 2) - assertThat(eb.toString()).isEqualTo("A") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("A") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_delete_selection_preserve() { - val eb = TextFieldBuffer("ABCDE", TextRange(2, 4)) + initialize("ABCDE", TextRange(2, 4)) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ACD") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(3) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ACD") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(3) + assertThat(state.composition).isNull() } @Test fun test_delete_before_too_many() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.deleteSurroundingText(1000, 0) + imeScope.deleteSurroundingText(1000, 0) - assertThat(eb.toString()).isEqualTo("DE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("DE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_delete_after_too_many() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.deleteSurroundingText(0, 1000) + imeScope.deleteSurroundingText(0, 1000) - assertThat(eb.toString()).isEqualTo("ABC") - assertThat(eb.selection.start).isEqualTo(3) - assertThat(eb.selection.end).isEqualTo(3) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABC") + assertThat(state.selection.start).isEqualTo(3) + assertThat(state.selection.end).isEqualTo(3) + assertThat(state.composition).isNull() } @Test fun test_delete_both_too_many() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.deleteSurroundingText(1000, 1000) + imeScope.deleteSurroundingText(1000, 1000) - assertThat(eb.toString()).isEqualTo("") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_delete_composition_no_intersection_preceding_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.setComposition(0, 1) + imeScope.setComposingRegion(0, 1) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(1) + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(1) } @Test fun test_delete_composition_no_intersection_trailing_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.setComposition(4, 5) + imeScope.setComposingRegion(4, 5) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.composition?.start).isEqualTo(2) - assertThat(eb.composition?.end).isEqualTo(3) + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition?.start).isEqualTo(2) + assertThat(state.composition?.end).isEqualTo(3) } @Test fun test_delete_composition_intersection_preceding_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.setComposition(0, 3) + imeScope.setComposingRegion(0, 3) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(2) + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(2) } @Test fun test_delete_composition_intersection_trailing_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.setComposition(3, 5) + imeScope.setComposingRegion(3, 5) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.composition?.start).isEqualTo(2) - assertThat(eb.composition?.end).isEqualTo(3) + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition?.start).isEqualTo(2) + assertThat(state.composition?.end).isEqualTo(3) } @Test fun test_delete_covered_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.setComposition(2, 3) + imeScope.setComposingRegion(2, 3) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_delete_composition_covered() { - val eb = TextFieldBuffer("ABCDE", TextRange(3)) + initialize("ABCDE", TextRange(3)) - eb.setComposition(0, 5) + imeScope.setComposingRegion(0, 5) - eb.deleteSurroundingText(1, 1) + imeScope.deleteSurroundingText(1, 1) - assertThat(eb.toString()).isEqualTo("ABE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(3) + assertThat(state.text.toString()).isEqualTo("ABE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(3) } @Test fun throws_whenLengthBeforeInvalid() { - val eb = TextFieldBuffer("", TextRange(0)) + initialize("", TextRange(0)) val error = assertFailsWith { - eb.deleteSurroundingText(lengthBeforeCursor = -42, lengthAfterCursor = 0) + imeScope.deleteSurroundingText(lengthBeforeCursor = -42, lengthAfterCursor = 0) } assertThat(error).hasMessageThat().contains("-42") } @Test fun throws_whenLengthAfterInvalid() { - val eb = TextFieldBuffer("", TextRange(0)) + initialize("", TextRange(0)) val error = assertFailsWith { - eb.deleteSurroundingText(lengthBeforeCursor = 0, lengthAfterCursor = -42) + imeScope.deleteSurroundingText(lengthBeforeCursor = 0, lengthAfterCursor = -42) } assertThat(error).hasMessageThat().contains("-42") } @@ -260,26 +260,26 @@ class DeleteSurroundingTextCommandTest { val text = "abcde" val textAfterDelete = "abcd" val selection = TextRange(textAfterDelete.length) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingText(lengthBeforeCursor = 0, lengthAfterCursor = Int.MAX_VALUE) + imeScope.deleteSurroundingText(lengthBeforeCursor = 0, lengthAfterCursor = Int.MAX_VALUE) - assertThat(eb.toString()).isEqualTo(textAfterDelete) - assertThat(eb.selection.start).isEqualTo(textAfterDelete.length) - assertThat(eb.selection.end).isEqualTo(textAfterDelete.length) + assertThat(state.text.toString()).isEqualTo(textAfterDelete) + assertThat(state.selection.start).isEqualTo(textAfterDelete.length) + assertThat(state.selection.end).isEqualTo(textAfterDelete.length) } @Test fun deletes_whenLengthBeforeCursorOverflows_withMaxValue() { val text = "abcde" val selection = TextRange(1) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingText(lengthBeforeCursor = Int.MAX_VALUE, lengthAfterCursor = 0) + imeScope.deleteSurroundingText(lengthBeforeCursor = Int.MAX_VALUE, lengthAfterCursor = 0) - assertThat(eb.toString()).isEqualTo("bcde") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) + assertThat(state.text.toString()).isEqualTo("bcde") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) } @Test @@ -287,25 +287,31 @@ class DeleteSurroundingTextCommandTest { val text = "abcde" val textAfterDelete = "abcd" val selection = TextRange(textAfterDelete.length) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingText(lengthBeforeCursor = 0, lengthAfterCursor = Int.MAX_VALUE - 1) + imeScope.deleteSurroundingText( + lengthBeforeCursor = 0, + lengthAfterCursor = Int.MAX_VALUE - 1 + ) - assertThat(eb.toString()).isEqualTo(textAfterDelete) - assertThat(eb.selection.start).isEqualTo(textAfterDelete.length) - assertThat(eb.selection.end).isEqualTo(textAfterDelete.length) + assertThat(state.text.toString()).isEqualTo(textAfterDelete) + assertThat(state.selection.start).isEqualTo(textAfterDelete.length) + assertThat(state.selection.end).isEqualTo(textAfterDelete.length) } @Test fun deletes_whenLengthBeforeCursorOverflows() { val text = "abcde" val selection = TextRange(1) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingText(lengthBeforeCursor = Int.MAX_VALUE - 1, lengthAfterCursor = 0) + imeScope.deleteSurroundingText( + lengthBeforeCursor = Int.MAX_VALUE - 1, + lengthAfterCursor = 0 + ) - assertThat(eb.toString()).isEqualTo("bcde") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) + assertThat(state.text.toString()).isEqualTo("bcde") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt index de8cefae9756b..d55aae7d19e6e 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/DeleteSurroundingTextInCodePointsCommandTest.kt @@ -24,7 +24,7 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) -class DeleteSurroundingTextInCodePointsCommandTest { +internal class DeleteSurroundingTextInCodePointsCommandTest : ImeEditCommandTest() { val CH1 = "\uD83D\uDE00" // U+1F600 val CH2 = "\uD83D\uDE01" // U+1F601 val CH3 = "\uD83D\uDE02" // U+1F602 @@ -33,219 +33,219 @@ class DeleteSurroundingTextInCodePointsCommandTest { @Test fun test_delete_after() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(2)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(2)) - eb.deleteSurroundingTextInCodePoints(0, 1) + imeScope.deleteSurroundingTextInCodePoints(0, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH3$CH4$CH5") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH1$CH3$CH4$CH5") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_delete_before() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(2)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(2)) - eb.deleteSurroundingTextInCodePoints(1, 0) + imeScope.deleteSurroundingTextInCodePoints(1, 0) - assertThat(eb.toString()).isEqualTo("$CH2$CH3$CH4$CH5") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH2$CH3$CH4$CH5") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_delete_both() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition).isNull() } @Test fun test_delete_after_multiple() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(4)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(4)) - eb.deleteSurroundingTextInCodePoints(0, 2) + imeScope.deleteSurroundingTextInCodePoints(0, 2) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition).isNull() } @Test fun test_delete_before_multiple() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.deleteSurroundingTextInCodePoints(2, 0) + imeScope.deleteSurroundingTextInCodePoints(2, 0) - assertThat(eb.toString()).isEqualTo("$CH1$CH4$CH5") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH1$CH4$CH5") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_delete_both_multiple() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.deleteSurroundingTextInCodePoints(2, 2) + imeScope.deleteSurroundingTextInCodePoints(2, 2) - assertThat(eb.toString()).isEqualTo(CH1) - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo(CH1) + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_delete_selection_preserve() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(4, 8)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(4, 8)) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH3$CH4") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(6) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH1$CH3$CH4") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(6) + assertThat(state.composition).isNull() } @Test fun test_delete_before_too_many() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.deleteSurroundingTextInCodePoints(1000, 0) + imeScope.deleteSurroundingTextInCodePoints(1000, 0) - assertThat(eb.toString()).isEqualTo("$CH4$CH5") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH4$CH5") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_delete_after_too_many() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.deleteSurroundingTextInCodePoints(0, 1000) + imeScope.deleteSurroundingTextInCodePoints(0, 1000) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH3") - assertThat(eb.selection.start).isEqualTo(6) - assertThat(eb.selection.end).isEqualTo(6) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH3") + assertThat(state.selection.start).isEqualTo(6) + assertThat(state.selection.end).isEqualTo(6) + assertThat(state.composition).isNull() } @Test fun test_delete_both_too_many() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.deleteSurroundingTextInCodePoints(1000, 1000) + imeScope.deleteSurroundingTextInCodePoints(1000, 1000) - assertThat(eb.toString()).isEqualTo("") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_delete_composition_no_intersection_preceding_composition() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.setComposition(0, 2) + imeScope.setComposingRegion(0, 2) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(2) + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(2) } @Test fun test_delete_composition_no_intersection_trailing_composition() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.setComposition(8, 10) + imeScope.setComposingRegion(8, 10) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.composition?.start).isEqualTo(4) - assertThat(eb.composition?.end).isEqualTo(6) + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition?.start).isEqualTo(4) + assertThat(state.composition?.end).isEqualTo(6) } @Test fun test_delete_composition_intersection_preceding_composition() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.setComposition(0, 6) + imeScope.setComposingRegion(0, 6) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(4) + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(4) } @Test fun test_delete_composition_intersection_trailing_composition() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.setComposition(6, 10) + imeScope.setComposingRegion(6, 10) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.composition?.start).isEqualTo(4) - assertThat(eb.composition?.end).isEqualTo(6) + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition?.start).isEqualTo(4) + assertThat(state.composition?.end).isEqualTo(6) } @Test fun test_delete_covered_composition() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.setComposition(4, 6) + imeScope.setComposingRegion(4, 6) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition).isNull() } @Test fun test_delete_composition_covered() { - val eb = TextFieldBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) + initialize("$CH1$CH2$CH3$CH4$CH5", TextRange(6)) - eb.setComposition(0, 10) + imeScope.setComposingRegion(0, 10) - eb.deleteSurroundingTextInCodePoints(1, 1) + imeScope.deleteSurroundingTextInCodePoints(1, 1) - assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(6) + assertThat(state.text.toString()).isEqualTo("$CH1$CH2$CH5") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(6) } @Test fun throws_whenLengthBeforeInvalid() { - val eb = TextFieldBuffer("", TextRange(0)) + initialize("", TextRange(0)) val error = assertFailsWith { - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = 0, lengthAfterCursor = -42 ) @@ -255,10 +255,10 @@ class DeleteSurroundingTextInCodePointsCommandTest { @Test fun throws_whenLengthAfterInvalid() { - val eb = TextFieldBuffer("", TextRange(0)) + initialize("", TextRange(0)) val error = assertFailsWith { - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = -42, lengthAfterCursor = 0 ) @@ -271,64 +271,64 @@ class DeleteSurroundingTextInCodePointsCommandTest { val text = "abcde" val textAfterDelete = "abcd" val selection = TextRange(textAfterDelete.length) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = 0, lengthAfterCursor = Int.MAX_VALUE ) - assertThat(eb.toString()).isEqualTo(textAfterDelete) - assertThat(eb.selection.start).isEqualTo(textAfterDelete.length) - assertThat(eb.selection.end).isEqualTo(textAfterDelete.length) + assertThat(state.text.toString()).isEqualTo(textAfterDelete) + assertThat(state.selection.start).isEqualTo(textAfterDelete.length) + assertThat(state.selection.end).isEqualTo(textAfterDelete.length) } @Test fun deletes_whenLengthBeforeCursorOverflows_withMaxValue() { val text = "abcde" val selection = TextRange(1) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = Int.MAX_VALUE, lengthAfterCursor = 0 ) - assertThat(eb.toString()).isEqualTo("bcde") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) + assertThat(state.text.toString()).isEqualTo("bcde") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) } @Test fun deletes_whenBothOverflow_withMaxValue_cursorAtStart() { val text = "abcde" val selection = TextRange(0) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = Int.MAX_VALUE, lengthAfterCursor = Int.MAX_VALUE ) - assertThat(eb.toString()).isEqualTo("") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) + assertThat(state.text.toString()).isEqualTo("") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) } @Test fun deletes_whenBothOverflow_withMaxValue_cursorAtEnd() { val text = "abcde" val selection = TextRange(5) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = Int.MAX_VALUE, lengthAfterCursor = Int.MAX_VALUE ) - assertThat(eb.toString()).isEqualTo("") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) + assertThat(state.text.toString()).isEqualTo("") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) } @Test @@ -336,31 +336,31 @@ class DeleteSurroundingTextInCodePointsCommandTest { val text = "abcde" val textAfterDelete = "abcd" val selection = TextRange(textAfterDelete.length) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = 0, lengthAfterCursor = Int.MAX_VALUE - 1 ) - assertThat(eb.toString()).isEqualTo(textAfterDelete) - assertThat(eb.selection.start).isEqualTo(textAfterDelete.length) - assertThat(eb.selection.end).isEqualTo(textAfterDelete.length) + assertThat(state.text.toString()).isEqualTo(textAfterDelete) + assertThat(state.selection.start).isEqualTo(textAfterDelete.length) + assertThat(state.selection.end).isEqualTo(textAfterDelete.length) } @Test fun deletes_whenLengthBeforeCursorOverflows() { val text = "abcde" val selection = TextRange(1) - val eb = TextFieldBuffer(text, selection) + initialize(text, selection) - eb.deleteSurroundingTextInCodePoints( + imeScope.deleteSurroundingTextInCodePoints( lengthBeforeCursor = Int.MAX_VALUE - 1, lengthAfterCursor = 0 ) - assertThat(eb.toString()).isEqualTo("bcde") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) + assertThat(state.text.toString()).isEqualTo("bcde") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/FinishComposingTextCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/FinishComposingTextCommandTest.kt index 91f057960e939..3fd0c689f0c46 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/FinishComposingTextCommandTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/FinishComposingTextCommandTest.kt @@ -23,31 +23,31 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) -class FinishComposingTextCommandTest { +internal class FinishComposingTextCommandTest : ImeEditCommandTest() { @Test fun test_set() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposition(1, 4) - eb.finishComposingText() + imeScope.setComposingRegion(1, 4) + imeScope.finishComposingText() - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_preserve_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(1, 4)) + initialize("ABCDE", TextRange(1, 4)) - eb.setComposition(2, 5) - eb.finishComposingText() + imeScope.setComposingRegion(2, 5) + imeScope.finishComposingText() - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition).isNull() } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommandTest.kt new file mode 100644 index 0000000000000..9b085e824a00d --- /dev/null +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/ImeEditCommandTest.kt @@ -0,0 +1,32 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text.input.internal + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.text.TextRange + +internal open class ImeEditCommandTest { + protected var state = TextFieldState("", TextRange.Zero) + protected var transformedState = TransformedTextFieldState(state) + protected var imeScope: ImeEditCommandScope = DefaultImeEditCommandScope(transformedState) + + protected fun initialize(text: String, selection: TextRange) { + state = TextFieldState(text, selection) + transformedState = TransformedTextFieldState(state) + imeScope = DefaultImeEditCommandScope(transformedState) + } +} diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt index 8ed2671a56acf..55217463d2550 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingRegionCommandTest.kt @@ -23,115 +23,114 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) -class SetComposingRegionCommandTest { +internal class SetComposingRegionCommandTest : ImeEditCommandTest() { @Test fun test_set() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposingRegion(1, 4) + imeScope.setComposingRegion(1, 4) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(4) + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(4) } @Test fun test_preserve_ongoing_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposition(1, 3) + imeScope.setComposingRegion(1, 3) + imeScope.setComposingRegion(2, 4) - eb.setComposingRegion(2, 4) - - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(2) - assertThat(eb.composition?.end).isEqualTo(4) + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(2) + assertThat(state.composition?.end).isEqualTo(4) } @Test fun test_preserve_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(1, 4)) + initialize("ABCDE", TextRange(1, 4)) - eb.setComposingRegion(2, 4) + imeScope.setComposingRegion(2, 4) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(2) - assertThat(eb.composition?.end).isEqualTo(4) + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(2) + assertThat(state.composition?.end).isEqualTo(4) } @Test fun test_set_reversed() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposingRegion(4, 1) + imeScope.setComposingRegion(4, 1) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(4) + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(4) } @Test fun test_set_too_small() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposingRegion(-1000, -1000) + imeScope.setComposingRegion(-1000, -1000) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_set_too_large() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposingRegion(1000, 1000) + imeScope.setComposingRegion(1000, 1000) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_set_too_small_and_too_large() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposingRegion(-1000, 1000) + imeScope.setComposingRegion(-1000, 1000) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(5) + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(5) } @Test fun test_set_too_small_and_too_large_reversed() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposingRegion(1000, -1000) + imeScope.setComposingRegion(1000, -1000) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(5) + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(5) } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingTextCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingTextCommandTest.kt index 1ab9fe6889549..aa42148e8a52d 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingTextCommandTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetComposingTextCommandTest.kt @@ -23,197 +23,197 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) -class SetComposingTextCommandTest { +internal class SetComposingTextCommandTest : ImeEditCommandTest() { @Test fun test_insert_empty() { - val eb = TextFieldBuffer("", TextRange.Zero) + initialize("", TextRange.Zero) - eb.setComposingText("X", 1) + imeScope.setComposingText("X", 1) - assertThat(eb.toString()).isEqualTo("X") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(0) - assertThat(eb.composition?.end).isEqualTo(1) + assertThat(state.text.toString()).isEqualTo("X") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(0) + assertThat(state.composition?.end).isEqualTo(1) } @Test fun test_insert_cursor_tail() { - val eb = TextFieldBuffer("A", TextRange(1)) + initialize("A", TextRange(1)) - eb.setComposingText("X", 1) + imeScope.setComposingText("X", 1) - assertThat(eb.toString()).isEqualTo("AX") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(2) + assertThat(state.text.toString()).isEqualTo("AX") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(2) } @Test fun test_insert_cursor_head() { - val eb = TextFieldBuffer("A", TextRange(1)) + initialize("A", TextRange(1)) - eb.setComposingText("X", 0) + imeScope.setComposingText("X", 0) - assertThat(eb.toString()).isEqualTo("AX") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(2) + assertThat(state.text.toString()).isEqualTo("AX") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(2) } @Test fun test_insert_cursor_far_tail() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.setComposingText("X", 2) + imeScope.setComposingText("X", 2) - assertThat(eb.toString()).isEqualTo("AXBCDE") - assertThat(eb.selection.start).isEqualTo(3) - assertThat(eb.selection.end).isEqualTo(3) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(2) + assertThat(state.text.toString()).isEqualTo("AXBCDE") + assertThat(state.selection.start).isEqualTo(3) + assertThat(state.selection.end).isEqualTo(3) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(2) } @Test fun test_insert_cursor_far_head() { - val eb = TextFieldBuffer("ABCDE", TextRange(4)) + initialize("ABCDE", TextRange(4)) - eb.setComposingText("X", -2) + imeScope.setComposingText("X", -2) - assertThat(eb.toString()).isEqualTo("ABCDXE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(4) - assertThat(eb.composition?.end).isEqualTo(5) + assertThat(state.text.toString()).isEqualTo("ABCDXE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(4) + assertThat(state.composition?.end).isEqualTo(5) } @Test fun test_insert_empty_text_cursor_head() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.setComposingText("", 0) + imeScope.setComposingText("", 0) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_insert_empty_text_cursor_tail() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.setComposingText("", 1) + imeScope.setComposingText("", 1) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_insert_empty_text_cursor_far_tail() { - val eb = TextFieldBuffer("ABCDE", TextRange(1)) + initialize("ABCDE", TextRange(1)) - eb.setComposingText("", 2) + imeScope.setComposingText("", 2) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_insert_empty_text_cursor_far_head() { - val eb = TextFieldBuffer("ABCDE", TextRange(4)) + initialize("ABCDE", TextRange(4)) - eb.setComposingText("", -2) + imeScope.setComposingText("", -2) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNull() } @Test fun test_cancel_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposition(1, 4) // Mark "BCD" as composition - eb.setComposingText("X", 1) + imeScope.setComposingRegion(1, 4) // Mark "BCD" as composition + imeScope.setComposingText("X", 1) - assertThat(eb.toString()).isEqualTo("AXE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(2) + assertThat(state.text.toString()).isEqualTo("AXE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(2) } @Test fun test_replace_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(1, 4)) // select "BCD" + initialize("ABCDE", TextRange(1, 4)) // select "BCD" - eb.setComposingText("X", 1) + imeScope.setComposingText("X", 1) - assertThat(eb.toString()).isEqualTo("AXE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(2) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(2) + assertThat(state.text.toString()).isEqualTo("AXE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(2) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(2) } @Test fun test_composition_and_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(1, 3)) // select "BC" + initialize("ABCDE", TextRange(1, 3)) // select "BC" - eb.setComposition(2, 4) // Mark "CD" as composition - eb.setComposingText("X", 1) + imeScope.setComposingRegion(2, 4) // Mark "CD" as composition + imeScope.setComposingText("X", 1) // If composition and selection exists at the same time, replace composition and cancel // selection and place cursor. - assertThat(eb.toString()).isEqualTo("ABXE") - assertThat(eb.selection.start).isEqualTo(3) - assertThat(eb.selection.end).isEqualTo(3) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(2) - assertThat(eb.composition?.end).isEqualTo(3) + assertThat(state.text.toString()).isEqualTo("ABXE") + assertThat(state.selection.start).isEqualTo(3) + assertThat(state.selection.end).isEqualTo(3) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(2) + assertThat(state.composition?.end).isEqualTo(3) } @Test fun test_cursor_position_too_small() { - val eb = TextFieldBuffer("ABCDE", TextRange(5)) + initialize("ABCDE", TextRange(5)) - eb.setComposingText("X", -1000) + imeScope.setComposingText("X", -1000) - assertThat(eb.toString()).isEqualTo("ABCDEX") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(5) - assertThat(eb.composition?.end).isEqualTo(6) + assertThat(state.text.toString()).isEqualTo("ABCDEX") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(5) + assertThat(state.composition?.end).isEqualTo(6) } @Test fun test_cursor_position_too_large() { - val eb = TextFieldBuffer("ABCDE", TextRange(5)) + initialize("ABCDE", TextRange(5)) - eb.setComposingText("X", 1000) + imeScope.setComposingText("X", 1000) - assertThat(eb.toString()).isEqualTo("ABCDEX") - assertThat(eb.selection.start).isEqualTo(6) - assertThat(eb.selection.end).isEqualTo(6) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(5) - assertThat(eb.composition?.end).isEqualTo(6) + assertThat(state.text.toString()).isEqualTo("ABCDEX") + assertThat(state.selection.start).isEqualTo(6) + assertThat(state.selection.end).isEqualTo(6) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(5) + assertThat(state.composition?.end).isEqualTo(6) } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetSelectionCommandTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetSelectionCommandTest.kt index 60a15e81871d4..89a4dc2509ffe 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetSelectionCommandTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/SetSelectionCommandTest.kt @@ -23,105 +23,105 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) -class SetSelectionCommandTest { +internal class SetSelectionCommandTest : ImeEditCommandTest() { @Test fun test_set() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setSelection(1, 4) + imeScope.setSelection(1, 4) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(1) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(1) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition).isNull() } @Test fun test_preserve_ongoing_composition() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setComposition(1, 3) + imeScope.setComposingRegion(1, 3) - eb.setSelection(2, 4) + imeScope.setSelection(2, 4) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(4) - assertThat(eb.hasComposition()).isTrue() - assertThat(eb.composition?.start).isEqualTo(1) - assertThat(eb.composition?.end).isEqualTo(3) + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(4) + assertThat(state.composition).isNotNull() + assertThat(state.composition?.start).isEqualTo(1) + assertThat(state.composition?.end).isEqualTo(3) } @Test fun test_cancel_ongoing_selection() { - val eb = TextFieldBuffer("ABCDE", TextRange(1, 4)) + initialize("ABCDE", TextRange(1, 4)) - eb.setSelection(2, 5) + imeScope.setSelection(2, 5) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(2) - assertThat(eb.selection.end).isEqualTo(5) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(2) + assertThat(state.selection.end).isEqualTo(5) + assertThat(state.composition).isNull() } @Test fun test_set_reversed() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setSelection(4, 1) + imeScope.setSelection(4, 1) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(4) - assertThat(eb.selection.end).isEqualTo(1) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(4) + assertThat(state.selection.end).isEqualTo(1) + assertThat(state.composition).isNull() } @Test fun test_set_too_small() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setSelection(-1000, -1000) + imeScope.setSelection(-1000, -1000) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } @Test fun test_set_too_large() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setSelection(1000, 1000) + imeScope.setSelection(1000, 1000) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(5) - assertThat(eb.selection.end).isEqualTo(5) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(5) + assertThat(state.selection.end).isEqualTo(5) + assertThat(state.composition).isNull() } @Test fun test_set_too_too_large() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setSelection(0, 1000) + imeScope.setSelection(0, 1000) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(0) - assertThat(eb.selection.end).isEqualTo(5) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(0) + assertThat(state.selection.end).isEqualTo(5) + assertThat(state.composition).isNull() } @Test fun test_set_too_large_reversed() { - val eb = TextFieldBuffer("ABCDE", TextRange.Zero) + initialize("ABCDE", TextRange.Zero) - eb.setSelection(1000, 0) + imeScope.setSelection(1000, 0) - assertThat(eb.toString()).isEqualTo("ABCDE") - assertThat(eb.selection.start).isEqualTo(5) - assertThat(eb.selection.end).isEqualTo(0) - assertThat(eb.hasComposition()).isFalse() + assertThat(state.text.toString()).isEqualTo("ABCDE") + assertThat(state.selection.start).isEqualTo(5) + assertThat(state.selection.end).isEqualTo(0) + assertThat(state.composition).isNull() } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldBufferUseFromImeTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldBufferUseFromImeTest.kt index aa5b02b6ec4f9..731ee62a34297 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldBufferUseFromImeTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldBufferUseFromImeTest.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.TextHighlightType import androidx.compose.foundation.text.input.internal.matchers.assertThat +import androidx.compose.foundation.text.input.setSelectionCoerced import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange @@ -115,7 +116,7 @@ class TextFieldBufferUseFromImeTest { assertThat(eb.hasComposition()).isFalse() assertThat(eb.composition).isNull() - eb.setSelection(0, 2) // Set the selection again + eb.setSelectionCoerced(0, 2) // Set the selection again assertThat(eb).hasChars("XDE") assertThat(eb.selection.start).isEqualTo(0) assertThat(eb.selection.end).isEqualTo(2) @@ -143,11 +144,24 @@ class TextFieldBufferUseFromImeTest { assertThat(tfb.composition).isNull() } + @Test + fun replace_notChangingContent_stillClearsComposition() { + val eb = TextFieldBuffer(TextFieldCharSequence("ABC", TextRange.Zero, TextRange(0, 3))) + + eb.imeReplace(0, 3, "ABC") + + assertThat(eb).hasChars("ABC") + assertThat(eb.selection.start).isEqualTo(3) + assertThat(eb.selection.end).isEqualTo(3) + assertThat(eb.hasComposition()).isFalse() + assertThat(eb.composition).isNull() + } + @Test fun setSelection_coerces_whenNegativeStart() { val eb = TextFieldBuffer("ABCDE", TextRange.Zero) - eb.setSelection(-1, 1) + eb.setSelectionCoerced(-1, 1) assertThat(eb.selection.start).isEqualTo(0) assertThat(eb.selection.end).isEqualTo(1) @@ -157,7 +171,7 @@ class TextFieldBufferUseFromImeTest { fun setSelection_coerces_whenNegativeEnd() { val eb = TextFieldBuffer("ABCDE", TextRange.Zero) - eb.setSelection(1, -1) + eb.setSelectionCoerced(1, -1) assertThat(eb.selection.start).isEqualTo(1) assertThat(eb.selection.end).isEqualTo(0) @@ -166,7 +180,7 @@ class TextFieldBufferUseFromImeTest { @Test fun setSelection_allowReversedSelection() { val eb = TextFieldBuffer("ABCDE", TextRange.Zero) - eb.setSelection(4, 2) + eb.setSelectionCoerced(4, 2) assertThat(eb.selection).isEqualTo(TextRange(4, 2)) } @@ -553,7 +567,7 @@ class TextFieldBufferUseFromImeTest { assertThat(eb.highlight) .isEqualTo(Pair(TextHighlightType.HandwritingSelectPreview, TextRange(1, 3))) - eb.setSelection(0, 1) + eb.setSelectionCoerced(0, 1) assertThat(eb.highlight).isNull() } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt index a8a0970b34706..dabc30d92ce1a 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.delete import androidx.compose.ui.text.TextRange import com.google.common.truth.Truth.assertThat import kotlin.test.fail @@ -49,7 +50,7 @@ class TextFieldStateInternalBufferTest { if (restart) resetCalled++ else selectionCalled++ } - state.editAsUser { commitText("X", 1) } + state.withImeScope { commitText("X", 1) } val newState = state.value assertThat(newState.toString()).isEqualTo("XABCDE") @@ -71,7 +72,7 @@ class TextFieldStateInternalBufferTest { if (restart) resetCalled++ else selectionCalled++ } - state.editAsUser { setSelection(0, 2) } + state.withImeScope { setSelection(0, 2) } val newState = state.value assertThat(newState.toString()).isEqualTo("ABCDE") @@ -88,9 +89,6 @@ class TextFieldStateInternalBufferTest { var resetCalled = 0 var selectionCalled = 0 - state.addImeContentListener { _, _, restart -> - if (restart) resetCalled++ else selectionCalled++ - } val initialBuffer = state.mainBuffer state.syncMainBufferToTemporaryBuffer( @@ -99,13 +97,17 @@ class TextFieldStateInternalBufferTest { assertThat(state.mainBuffer).isNotSameInstanceAs(initialBuffer) val updatedBuffer = state.mainBuffer + + state.addImeContentListener { _, _, restart -> + if (restart) resetCalled++ else selectionCalled++ + } state.syncMainBufferToTemporaryBuffer( TextFieldCharSequence("qwerty", TextRange.Zero, TextRange.Zero) ) assertThat(state.mainBuffer).isSameInstanceAs(updatedBuffer) - assertThat(resetCalled).isEqualTo(2) - assertThat(selectionCalled).isEqualTo(0) + assertThat(resetCalled).isEqualTo(0) + assertThat(selectionCalled).isEqualTo(1) } @Test @@ -126,8 +128,9 @@ class TextFieldStateInternalBufferTest { state.syncMainBufferToTemporaryBuffer(newTextFieldValue) assertThat(state.mainBuffer).isNotSameInstanceAs(initialBuffer) - assertThat(resetCalled).isEqualTo(2) - assertThat(selectionCalled).isEqualTo(0) + // no composing region, reset shouldn't be called + assertThat(resetCalled).isEqualTo(0) + assertThat(selectionCalled).isEqualTo(2) } @Test @@ -136,22 +139,22 @@ class TextFieldStateInternalBufferTest { var resetCalled = 0 var selectionCalled = 0 - state.addImeContentListener { _, _, restart -> - if (restart) resetCalled++ else selectionCalled++ - } val textFieldValue = TextFieldCharSequence("qwerty", TextRange.Zero, TextRange.Zero) state.syncMainBufferToTemporaryBuffer(textFieldValue) val initialBuffer = state.mainBuffer + state.addImeContentListener { _, _, restart -> + if (restart) resetCalled++ else selectionCalled++ + } val newTextFieldValue = TextFieldCharSequence(textFieldValue, selection = TextRange(1)) state.syncMainBufferToTemporaryBuffer(newTextFieldValue) assertThat(state.mainBuffer).isSameInstanceAs(initialBuffer) assertThat(newTextFieldValue.selection.start).isEqualTo(state.mainBuffer.selection.start) assertThat(newTextFieldValue.selection.end).isEqualTo(state.mainBuffer.selection.end) - assertThat(resetCalled).isEqualTo(2) - assertThat(selectionCalled).isEqualTo(0) + assertThat(resetCalled).isEqualTo(0) + assertThat(selectionCalled).isEqualTo(1) } @Test @@ -160,25 +163,24 @@ class TextFieldStateInternalBufferTest { var resetCalled = 0 var selectionCalled = 0 - state.addImeContentListener { _, _, restart -> - if (restart) resetCalled++ else selectionCalled++ - } val textFieldValue = TextFieldCharSequence("qwerty", TextRange.Zero, TextRange(1)) state.syncMainBufferToTemporaryBuffer(textFieldValue) val initialBuffer = state.mainBuffer + state.addImeContentListener { _, _, restart -> + if (restart) resetCalled++ else selectionCalled++ + } // composition can not be set from app, IME owns it. assertThat(initialBuffer.composition).isNull() - val newTextFieldValue = - TextFieldCharSequence(textFieldValue, textFieldValue.selection, composition = null) + val newTextFieldValue = TextFieldCharSequence("qwerty", TextRange.Zero, composition = null) state.syncMainBufferToTemporaryBuffer(newTextFieldValue) assertThat(state.mainBuffer).isSameInstanceAs(initialBuffer) assertThat(state.mainBuffer.composition).isNull() - assertThat(resetCalled).isEqualTo(2) - assertThat(selectionCalled).isEqualTo(0) + assertThat(resetCalled).isEqualTo(0) + assertThat(selectionCalled).isEqualTo(1) } @Test @@ -206,8 +208,9 @@ class TextFieldStateInternalBufferTest { assertThat(state.mainBuffer).isSameInstanceAs(initialBuffer) assertThat(updatedSelection.start).isEqualTo(initialBuffer.selection.start) assertThat(updatedSelection.end).isEqualTo(initialBuffer.selection.end) - assertThat(resetCalled).isEqualTo(1) - assertThat(selectionCalled).isEqualTo(0) + // content does not change so restart input is unexpected + assertThat(resetCalled).isEqualTo(0) + assertThat(selectionCalled).isEqualTo(1) } @Test @@ -221,7 +224,7 @@ class TextFieldStateInternalBufferTest { } // set the initial value - state.editAsUser { + state.withImeScope { commitText("ab", 0) setComposingRegion(0, 2) } @@ -239,12 +242,12 @@ class TextFieldStateInternalBufferTest { } @Test - fun compositionIsCleared_when_textIsSame() { + fun compositionIsNotCleared_when_textIsSame() { val state = TextFieldState() val composition = TextRange(0, 2) // set the initial value - state.editAsUser { + state.withImeScope { commitText("ab", 0) setComposingRegion(composition.start, composition.end) } @@ -254,7 +257,7 @@ class TextFieldStateInternalBufferTest { state.syncMainBufferToTemporaryBuffer(newValue) assertThat(state.text.toString()).isEqualTo(newValue.toString()) - assertThat(state.composition).isNull() + assertThat(state.composition).isEqualTo(TextRange(0, 2)) } @Test @@ -262,7 +265,7 @@ class TextFieldStateInternalBufferTest { val state = TextFieldState() // set the initial value - state.editAsUser { + state.withImeScope { commitText("ab", 0) setComposingRegion(-1, -1) } @@ -281,7 +284,7 @@ class TextFieldStateInternalBufferTest { val state = TextFieldState() // set the initial value - state.editAsUser { + state.withImeScope { commitText("ab", 0) setComposingRegion(0, 2) } @@ -303,7 +306,7 @@ class TextFieldStateInternalBufferTest { val selection = TextRange(0, 2) // set the initial value - state.editAsUser { + state.withImeScope { commitText("ab", 0) setComposingRegion(composition.start, composition.end) setSelection(selection.start, selection.end) @@ -337,7 +340,7 @@ class TextFieldStateInternalBufferTest { val initialBuffer = state.mainBuffer - state.editAsUser { commitText("d", 4) } + state.withImeScope { commitText("d", 4) } val value = state.text @@ -358,7 +361,7 @@ class TextFieldStateInternalBufferTest { val initialBuffer = state.mainBuffer - state.editAsUser { commitText("d", 4) } + state.withImeScope { commitText("d", 4) } val value = state.text @@ -386,12 +389,7 @@ class TextFieldStateInternalBufferTest { val initialBuffer = state.mainBuffer - state.editAsUser( - inputTransformation = { revertAllChanges() }, - restartImeIfContentChanges = false - ) { - commitText("d", 4) - } + state.withImeScope(inputTransformation = { revertAllChanges() }) { commitText("d", 4) } val value = state.text @@ -410,7 +408,7 @@ class TextFieldStateInternalBufferTest { fail("filter ran, old=\"${originalValue}\", new=\"${toTextFieldCharSequence()}\"") } - state.editAsUser(inputTransformation, restartImeIfContentChanges = false) {} + state.withImeScope(inputTransformation) {} } @Test @@ -422,12 +420,7 @@ class TextFieldStateInternalBufferTest { fail("filter ran, old=\"${originalValue}\", new=\"${toTextFieldCharSequence()}\"") } - state.editAsUser( - inputTransformation = inputTransformation, - restartImeIfContentChanges = false - ) { - finishComposingText() - } + state.withImeScope(inputTransformation = inputTransformation) { finishComposingText() } } @Test @@ -439,12 +432,7 @@ class TextFieldStateInternalBufferTest { fail("filter ran, old=\"${originalValue}\", new=\"${toTextFieldCharSequence()}\"") } - state.editAsUser( - inputTransformation = inputTransformation, - restartImeIfContentChanges = false - ) { - finishComposingText() - } + state.withImeScope(inputTransformation = inputTransformation) { finishComposingText() } } @Test @@ -460,10 +448,7 @@ class TextFieldStateInternalBufferTest { ) } - state.editAsUser( - inputTransformation = inputTransformation, - restartImeIfContentChanges = false - ) { + state.withImeScope(inputTransformation = inputTransformation) { setComposingRegion(0, 5) commitText("hello", 1) setSelection(2, 2) @@ -487,12 +472,7 @@ class TextFieldStateInternalBufferTest { assertThat(new.selection).isEqualTo(TextRange(0, 5)) } - state.editAsUser( - inputTransformation = inputTransformation, - restartImeIfContentChanges = false - ) { - setSelection(0, 5) - } + state.withImeScope(inputTransformation = inputTransformation) { setSelection(0, 5) } } @Test @@ -512,11 +492,8 @@ class TextFieldStateInternalBufferTest { assertThat(new.toString()).isEqualTo("world") } - state.editAsUser( - inputTransformation = inputTransformation, - restartImeIfContentChanges = false - ) { - deleteAll() + state.withImeScope(inputTransformation = inputTransformation) { + edit { delete(0, length) } commitText("world", 1) setSelection(2, 2) } @@ -528,7 +505,7 @@ class TextFieldStateInternalBufferTest { TextFieldCharSequence("hello", selection = TextRange(5), composition = TextRange(0, 5)) val state = TextFieldState(initialValue) - state.editAsUser { setComposingRegion(2, 3) } + state.withImeScope { setComposingRegion(2, 3) } assertThat(state.composition).isEqualTo(TextRange(2, 3)) } @@ -539,7 +516,7 @@ class TextFieldStateInternalBufferTest { TextFieldCharSequence("hello", selection = TextRange(5), composition = TextRange(0, 5)) val state = TextFieldState(initialValue) - state.editAsUser { setComposingRegion(2, 3) } + state.withImeScope { setComposingRegion(2, 3) } assertThat(state.composition).isEqualTo(TextRange(2, 3)) } @@ -547,10 +524,6 @@ class TextFieldStateInternalBufferTest { private fun TextFieldState(value: TextFieldCharSequence) = TextFieldState(value.toString(), value.selection) - private fun TextFieldState.editAsUser(block: TextFieldBuffer.() -> Unit) { - editAsUser(inputTransformation = null, restartImeIfContentChanges = false, block = block) - } - private fun TextFieldState.addImeContentListener( listener: (TextFieldCharSequence, TextFieldCharSequence, Boolean) -> Unit ) { @@ -569,3 +542,15 @@ class TextFieldStateInternalBufferTest { ) } } + +internal fun TextFieldState.withImeScope( + inputTransformation: InputTransformation? = null, + block: ImeEditCommandScope.() -> Unit +) { + val transformedState = TransformedTextFieldState(this, inputTransformation) + with(DefaultImeEditCommandScope(transformedState)) { + beginBatchEdit() + block() + endBatchEdit() + } +} diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldStateTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldStateTest.kt index 1d946682b0f4f..5045914f7aae4 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldStateTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldStateTest.kt @@ -17,11 +17,18 @@ package androidx.compose.foundation.text.input.internal import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.PlacedAnnotation +import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.delete import androidx.compose.foundation.text.input.insert +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @@ -44,6 +51,23 @@ class TransformedTextFieldStateTest { assertThat(transformedState.visualText.toString()).isEqualTo("helloworld") } + @Test + fun outputTransformationDoesNotRemoveComposingAnnotations() { + val state = TextFieldState() + val outputTransformation = OutputTransformation { append(" world") } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + val annotations: List = + listOf(AnnotatedString.Range(SpanStyle(background = Color.Blue), 0, 5)) + DefaultImeEditCommandScope(transformedState) + .setComposingText(text = "hello", newCursorPosition = 1, annotations = annotations) + + assertThat(transformedState.visualText.composingAnnotations).isEqualTo(annotations) + } + @Test fun mapToTransformed_insertions() { val state = TextFieldState("zzzz") @@ -213,4 +237,196 @@ class TransformedTextFieldStateTest { assertThat(transformedState.outputText.selection).isEqualTo(TextRange(0, 4)) // Rest of indices and wedge affinity are covered by mapToTransformed tests. } + + @Test + fun collectImeNotifications_usesVisualText() = runTest { + val state = TextFieldState("hello") + val outputTransformation = OutputTransformation { + insert(0, "a") + insert(length, "a") + } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + + val collectedOldValues = mutableListOf() + val collectedNewValues = mutableListOf() + val collectedRestartImes = mutableListOf() + val job = launch { + transformedState.collectImeNotifications { oldValue, newValue, restartIme -> + collectedOldValues += oldValue + collectedNewValues += newValue + collectedRestartImes += restartIme + } + } + + testScheduler.advanceUntilIdle() + + transformedState.editUntransformedTextAsUser(restartImeIfContentChanges = false) { + append(" world") + } + + testScheduler.advanceUntilIdle() + + assertThat(collectedOldValues) + .containsExactly(TextFieldCharSequence("ahelloa", selection = TextRange(6))) + assertThat(collectedNewValues) + .containsExactly(TextFieldCharSequence("ahello worlda", selection = TextRange(12))) + assertThat(collectedRestartImes).containsExactly(false) + job.cancel() + } + + @Test + fun collectImeNotifications_carriesRestartImeUnchanged() = runTest { + val state = TextFieldState("hello").apply { editAsUser(null) { setComposition(0, 5) } } + val outputTransformation = OutputTransformation { + insert(0, "a") + insert(length, "a") + } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + + val collectedOldValues = mutableListOf() + val collectedNewValues = mutableListOf() + val collectedRestartImes = mutableListOf() + val job = launch { + transformedState.collectImeNotifications { oldValue, newValue, restartIme -> + collectedOldValues += oldValue + collectedNewValues += newValue + collectedRestartImes += restartIme + } + } + + testScheduler.advanceUntilIdle() + + transformedState.editUntransformedTextAsUser(restartImeIfContentChanges = true) { + append(" world") + } + + testScheduler.advanceUntilIdle() + + assertThat(collectedOldValues) + .containsExactly( + TextFieldCharSequence( + "ahelloa", + selection = TextRange(6), + composition = TextRange(0, 6) + ) + ) + assertThat(collectedNewValues) + .containsExactly( + TextFieldCharSequence( + "ahello worlda", + selection = TextRange(12), + composition = TextRange(0, 6) + ) + ) + assertThat(collectedRestartImes).containsExactly(true) + job.cancel() + } + + @Test + fun wedgeAffinity_resetsBackToStartAffinity_afterEditUntransformedTextAsUser() { + val state = TextFieldState("hello") + val outputTransformation = OutputTransformation { insert(0, "aa") } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + + transformedState.selectCharsIn(TextRange(0, 4)) + transformedState.selectionWedgeAffinity = + SelectionWedgeAffinity(WedgeAffinity.Start, WedgeAffinity.End) + + transformedState.editUntransformedTextAsUser { delete(0, 2) } + + assertThat(transformedState.selectionWedgeAffinity) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) + } + + @Test + fun wedgeAffinity_resetsBackToStartAffinity_afterReplaceAll() { + val state = TextFieldState("hello") + val outputTransformation = OutputTransformation { insert(0, "aa") } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + + transformedState.selectCharsIn(TextRange(0, 4)) + transformedState.selectionWedgeAffinity = + SelectionWedgeAffinity(WedgeAffinity.Start, WedgeAffinity.End) + + transformedState.replaceAll("world") + + assertThat(transformedState.selectionWedgeAffinity) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) + } + + @Test + fun wedgeAffinity_resetsBackToStartAffinity_afterDeleteSelectedText() { + val state = TextFieldState("hello") + val outputTransformation = OutputTransformation { insert(0, "aa") } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + + transformedState.selectCharsIn(TextRange(0, 4)) + transformedState.selectionWedgeAffinity = + SelectionWedgeAffinity(WedgeAffinity.Start, WedgeAffinity.End) + + transformedState.deleteSelectedText() + + assertThat(transformedState.selectionWedgeAffinity) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) + } + + @Test + fun wedgeAffinity_resetsBackToStartAffinity_afterReplaceText() { + val state = TextFieldState("hello") + val outputTransformation = OutputTransformation { insert(0, "aa") } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + + transformedState.selectCharsIn(TextRange(0, 4)) + transformedState.selectionWedgeAffinity = + SelectionWedgeAffinity(WedgeAffinity.Start, WedgeAffinity.End) + + transformedState.replaceText("world", TextRange(0, 3)) + + assertThat(transformedState.selectionWedgeAffinity) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) + } + + @Test + fun wedgeAffinity_resetsBackToStartAffinity_afterReplaceSelectedText() { + val state = TextFieldState("hello") + val outputTransformation = OutputTransformation { insert(0, "aa") } + val transformedState = + TransformedTextFieldState( + textFieldState = state, + outputTransformation = outputTransformation + ) + + transformedState.selectCharsIn(TextRange(0, 4)) + transformedState.selectionWedgeAffinity = + SelectionWedgeAffinity(WedgeAffinity.Start, WedgeAffinity.End) + + transformedState.replaceSelectedText("world") + + assertThat(transformedState.selectionWedgeAffinity) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) + } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt index cdb98df96efbb..5befc91b147a6 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt @@ -16,11 +16,13 @@ package androidx.compose.foundation.text.input.internal +import androidx.compose.foundation.text.findFollowingBreak +import androidx.compose.foundation.text.findPrecedingBreak import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.delete import androidx.compose.foundation.text.input.insert -import androidx.compose.foundation.text.input.internal.selection.calculateAdjacentCursorPosition +import androidx.compose.foundation.text.input.internal.selection.calculateNextCursorPositionAndWedgeAffinity import androidx.compose.ui.text.TextRange import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -86,7 +88,7 @@ class TransformedTextSelectionMovementTest { assertThat(state.selection).isEqualTo(TextRange(2)) assertThat(transformedState.visualText.selection).isEqualTo(TextRange(4)) assertThat(transformedState.selectionWedgeAffinity) - .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.End)) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) } @Test @@ -115,7 +117,7 @@ class TransformedTextSelectionMovementTest { assertThat(state.selection).isEqualTo(TextRange(0)) assertThat(transformedState.visualText.selection).isEqualTo(TextRange(0)) assertThat(transformedState.selectionWedgeAffinity) - .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start)) + .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.End)) } @Test @@ -156,24 +158,26 @@ class TransformedTextSelectionMovementTest { } private fun calculateNextCursorPosition(state: TransformedTextFieldState) { - val newCursor = - calculateAdjacentCursorPosition( - state.visualText.toString(), - state.visualText.selection.end, - forward = true, - state + val cursor = state.visualText.selection.end + val (newCursor, wedgeAffinity) = + calculateNextCursorPositionAndWedgeAffinity( + proposedCursor = state.visualText.toString().findFollowingBreak(cursor), + cursor = cursor, + transformedTextFieldState = state ) state.placeCursorBeforeCharAt(newCursor) + wedgeAffinity?.let { state.selectionWedgeAffinity = SelectionWedgeAffinity(wedgeAffinity) } } private fun calculatePreviousCursorPosition(state: TransformedTextFieldState) { - val newCursor = - calculateAdjacentCursorPosition( - state.visualText.toString(), - state.visualText.selection.end, - forward = false, - state + val cursor = state.visualText.selection.end + val (newCursor, wedgeAffinity) = + calculateNextCursorPositionAndWedgeAffinity( + proposedCursor = state.visualText.toString().findPrecedingBreak(cursor), + cursor = cursor, + transformedTextFieldState = state ) state.placeCursorBeforeCharAt(newCursor) + wedgeAffinity?.let { state.selectionWedgeAffinity = SelectionWedgeAffinity(wedgeAffinity) } } } diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt index 0becec6554665..a30b908443e10 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt @@ -21,8 +21,11 @@ import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.allCaps import androidx.compose.foundation.text.input.delete +import androidx.compose.foundation.text.input.internal.DefaultImeEditCommandScope +import androidx.compose.foundation.text.input.internal.TransformedTextFieldState import androidx.compose.foundation.text.input.internal.commitText -import androidx.compose.foundation.text.input.internal.setSelection +import androidx.compose.foundation.text.input.internal.finishComposingText +import androidx.compose.foundation.text.input.setSelectionCoerced import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.intl.Locale import com.google.common.truth.Truth.assertThat @@ -278,11 +281,11 @@ class TextUndoTest { // this test also tests for AllCapsTransformation state.editAsUser(inputTransformation = allCapsTransformation) { commitComposition() - commitText("d", 1) + append("d") } // "abcD|" state.editAsUser(inputTransformation = allCapsTransformation) { commitComposition() - commitText("e", 1) + append("e") } // "abcDE|" state.undoState.undo() // "abc|" @@ -352,9 +355,11 @@ class TextUndoTest { } private fun TextFieldState.type(text: String) { - editAsUser(inputTransformation = null) { - commitComposition() + with(DefaultImeEditCommandScope(TransformedTextFieldState(this))) { + beginBatchEdit() + finishComposingText() commitText(text, 1) + endBatchEdit() } } @@ -367,7 +372,7 @@ class TextUndoTest { } private fun TextFieldState.select(start: Int, end: Int) { - editAsUser(inputTransformation = null) { setSelection(start, end) } + editAsUser(inputTransformation = null) { setSelectionCoerced(start, end) } } private fun TextFieldState.replaceAt(start: Int, end: Int, newText: String) { diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/SelectionManagerTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/SelectionManagerTest.kt index 7df1f0ea67f9b..30f1bd4c83575 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/SelectionManagerTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/SelectionManagerTest.kt @@ -890,7 +890,7 @@ class SelectionManagerTest { selectionManager.showToolbar = true - verify(textToolbar, times(1)).showMenu(any(), any(), isNull(), isNull(), any()) + verify(textToolbar, times(1)).showMenu(any(), any(), isNull(), isNull(), any(), isNull()) } @Test @@ -922,7 +922,7 @@ class SelectionManagerTest { selectionManager.showToolbar = true - verify(textToolbar, never()).showMenu(any(), any(), isNull(), isNull(), isNull()) + verify(textToolbar, never()).showMenu(any(), any(), isNull(), isNull(), isNull(), any()) } @Test diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt index cf2c5ae912545..4de27459e3356 100644 --- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt +++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt @@ -16,10 +16,13 @@ package androidx.compose.foundation.text.selection +import android.os.Build +import androidx.annotation.RequiresApi import androidx.compose.foundation.text.HandleState import androidx.compose.foundation.text.LegacyTextFieldState import androidx.compose.foundation.text.TextDelegate import androidx.compose.foundation.text.TextLayoutResultProxy +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -90,6 +93,7 @@ class TextFieldSelectionManagerTest { private val hapticFeedback = mock() private val focusRequester = mock() private val multiParagraph = mock() + private val autofillManager = mock() @Before fun setup() { @@ -101,6 +105,7 @@ class TextFieldSelectionManagerTest { manager.textToolbar = textToolbar manager.hapticFeedBack = hapticFeedback manager.focusRequester = focusRequester + manager.autofillManager = autofillManager whenever(layoutResult.layoutInput) .thenReturn( @@ -355,6 +360,17 @@ class TextFieldSelectionManagerTest { assertThat(state.handleState).isEqualTo(HandleState.None) } + @RequiresApi(Build.VERSION_CODES.O) + @Test + fun autofill_selection_collapse() { + manager.value = TextFieldValue(text = text, selection = TextRange(4, 4)) + + manager.autofill() + + verify(autofillManager, times(1)).requestAutofillForActiveElement() + assertThat(state.handleState).isEqualTo(HandleState.None) + } + @Test fun copy_selection_collapse() { manager.value = TextFieldValue(text = text, selection = TextRange(4, 4)) @@ -500,7 +516,7 @@ class TextFieldSelectionManagerTest { manager.showSelectionToolbar() - verify(textToolbar, times(1)).showMenu(any(), any(), isNull(), any(), anyOrNull()) + verify(textToolbar, times(1)).showMenu(any(), any(), isNull(), any(), anyOrNull(), isNull()) } @Test @@ -512,7 +528,7 @@ class TextFieldSelectionManagerTest { manager.showSelectionToolbar() verify(textToolbar, times(1)) - .showMenu(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + .showMenu(anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull(), isNull()) } @Test @@ -523,7 +539,7 @@ class TextFieldSelectionManagerTest { manager.showSelectionToolbar() - verify(textToolbar, times(1)).showMenu(any(), isNull(), any(), isNull(), anyOrNull()) + verify(textToolbar, times(1)).showMenu(any(), isNull(), any(), isNull(), anyOrNull(), any()) } @Test @@ -533,7 +549,7 @@ class TextFieldSelectionManagerTest { manager.showSelectionToolbar() - verify(textToolbar, times(1)).showMenu(any(), isNull(), any(), isNull(), isNull()) + verify(textToolbar, times(1)).showMenu(any(), isNull(), any(), isNull(), isNull(), any()) } @Test @@ -544,7 +560,7 @@ class TextFieldSelectionManagerTest { manager.showSelectionToolbar() - verify(textToolbar, times(1)).showMenu(any(), isNull(), isNull(), isNull(), isNull()) + verify(textToolbar, times(1)).showMenu(any(), isNull(), isNull(), isNull(), isNull(), any()) } @Test @@ -556,7 +572,8 @@ class TextFieldSelectionManagerTest { manager.showSelectionToolbar() - verify(textToolbar, times(1)).showMenu(any(), isNull(), any(), isNull(), anyOrNull()) + verify(textToolbar, times(1)) + .showMenu(any(), isNull(), any(), isNull(), anyOrNull(), isNull()) } @Test diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt index e52dd97ff29d8..62a88108da729 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.focus.FocusState import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.layout.IntrinsicMeasurable import androidx.compose.ui.layout.IntrinsicMeasureScope import androidx.compose.ui.layout.LayoutCoordinates @@ -55,11 +57,13 @@ import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.requireDensity +import androidx.compose.ui.node.requireGraphicsContext import androidx.compose.ui.node.requireLayoutDirection import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp @@ -208,6 +212,7 @@ private class MarqueeModifierNode( private var containerWidth by mutableIntStateOf(0) private var hasFocus by mutableStateOf(false) private var animationJob: Job? = null + private var marqueeLayer: GraphicsLayer? = null var spacing: MarqueeSpacing by mutableStateOf(spacing) var animationMode: MarqueeAnimationMode by mutableStateOf(animationMode) @@ -225,12 +230,27 @@ private class MarqueeModifierNode( } override fun onAttach() { + val layer = marqueeLayer + val graphicsContext = requireGraphicsContext() + // Shouldn't happen as detach should be called in between in onAttach call but + // just in case + if (layer != null) { + graphicsContext.releaseGraphicsLayer(layer) + } + + marqueeLayer = graphicsContext.createGraphicsLayer() restartAnimation() } override fun onDetach() { animationJob?.cancel() animationJob = null + + val layer = marqueeLayer + if (layer != null) { + requireGraphicsContext().releaseGraphicsLayer(layer) + marqueeLayer = null + } } fun update( @@ -320,17 +340,31 @@ private class MarqueeModifierNode( else -> -contentWidth - spacingPx }.toFloat() - clipRect(left = clipOffset, right = clipOffset + containerWidth) { - // TODO(b/262284225) When both copies are visible, we call drawContent twice. This is - // generally a bad practice, however currently the only alternative is to compose the - // content twice, which can't be done with a modifier. In the future we might get the - // ability to create intrinsic layers in draw scopes, which we should use here to avoid - // invalidating the contents' draw scopes. - if (firstCopyVisible) { + val drawHeight = size.height + marqueeLayer?.let { layer -> + layer.record(size = IntSize(contentWidth, drawHeight.roundToInt())) { this@draw.drawContent() } - if (secondCopyVisible) { - translate(left = secondCopyOffset) { this@draw.drawContent() } + } + clipRect(left = clipOffset, right = clipOffset + containerWidth) { + val layer = marqueeLayer + // Unless there are circumstances where the Modifier's draw call can be invoked without + // an attach call, the else case here is optional. However we can be safe and make sure + // that we definitely draw even when the layer could not be initialized for any reason. + if (layer != null) { + if (firstCopyVisible) { + drawLayer(layer) + } + if (secondCopyVisible) { + translate(left = secondCopyOffset) { drawLayer(layer) } + } + } else { + if (firstCopyVisible) { + this@draw.drawContent() + } + if (secondCopyVisible) { + translate(left = secondCopyOffset) { this@draw.drawContent() } + } } } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt index 14214f75a1c34..488b6eb6777ff 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt @@ -33,9 +33,13 @@ import androidx.compose.ui.focus.requestFocus import androidx.compose.ui.geometry.Offset import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType.Companion.KeyDown +import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp import androidx.compose.ui.input.key.KeyInputModifierNode import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType @@ -496,11 +500,29 @@ internal expect val TapIndicationDelay: Long */ internal expect fun DelegatableNode.isComposeRootInScrollableContainer(): Boolean -/** Whether the specified [KeyEvent] should trigger a press for a clickable component. */ -internal expect val KeyEvent.isPress: Boolean +/** + * Whether the specified [KeyEvent] should trigger a press for a clickable component, i.e. whether + * it is associated with a press of an enter key or dpad centre. + */ +internal val KeyEvent.isPress: Boolean + get() = type == KeyDown && isEnter -/** Whether the specified [KeyEvent] should trigger a click for a clickable component. */ -internal expect val KeyEvent.isClick: Boolean +/** + * Whether the specified [KeyEvent] should trigger a click for a clickable component, i.e. whether + * it is associated with a release of an enter key or dpad centre. + */ +internal val KeyEvent.isClick: Boolean + get() = type == KeyUp && isEnter + +private val KeyEvent.isEnter: Boolean + get() = + when (key) { + Key.DirectionCenter, + Key.Enter, + Key.NumPadEnter, + Key.Spacebar -> true + else -> false + } private class ClickableElement( private val interactionSource: MutableInteractionSource?, @@ -573,9 +595,9 @@ private class CombinedClickableElement( private val onLongClick: (() -> Unit)?, private val onDoubleClick: (() -> Unit)?, private val hapticFeedbackEnabled: Boolean, -) : ModifierNodeElement() { +) : ModifierNodeElement() { override fun create() = - CombinedClickableNodeImpl( + CombinedClickableNode( onClick, onLongClickLabel, onLongClick, @@ -588,7 +610,7 @@ private class CombinedClickableElement( role, ) - override fun update(node: CombinedClickableNodeImpl) { + override fun update(node: CombinedClickableNode) { node.hapticFeedbackEnabled = hapticFeedbackEnabled node.update( onClick, @@ -702,94 +724,7 @@ internal open class ClickableNode( } } -/** - * Create a [CombinedClickableNode] that can be delegated to inside custom modifier nodes. - * - * This API is experimental and is temporarily being exposed to enable performance analysis, you - * should use [combinedClickable] instead for the majority of use cases. - * - * @param onClick will be called when user clicks on the element - * @param onLongClickLabel semantic / accessibility label for the [onLongClick] action - * @param onLongClick will be called when user long presses on the element - * @param onDoubleClick will be called when user double clicks on the element - * @param interactionSource [MutableInteractionSource] that will be used to emit - * [PressInteraction.Press] when this clickable is pressed. Only the initial (first) press will be - * recorded and emitted with [MutableInteractionSource]. If `null`, and there is an - * [indicationNodeFactory] provided, an internal [MutableInteractionSource] will be created when - * required. - * @param indicationNodeFactory the [IndicationNodeFactory] used to optionally render [Indication] - * inside this node, instead of using a separate [Modifier.indication]. This should be preferred - * for performance reasons over using [Modifier.indication] separately. - * @param enabled Controls the enabled state. When false, [onClick], [onLongClick] or - * [onDoubleClick] won't be invoked - * @param onClickLabel semantic / accessibility label for the [onClick] action - * @param role the type of user interface element. Accessibility services might use this to describe - * the element or do customizations - */ -fun CombinedClickableNode( - onClick: () -> Unit, - onLongClickLabel: String?, - onLongClick: (() -> Unit)?, - onDoubleClick: (() -> Unit)?, - interactionSource: MutableInteractionSource?, - indicationNodeFactory: IndicationNodeFactory?, - enabled: Boolean, - onClickLabel: String?, - role: Role?, -): CombinedClickableNode = - CombinedClickableNodeImpl( - onClick, - onLongClickLabel, - onLongClick, - onDoubleClick, - hapticFeedbackEnabled = true, - interactionSource, - indicationNodeFactory, - enabled, - onClickLabel, - role, - ) - -/** - * Public interface for the internal node used inside [combinedClickable], to allow for custom - * modifier nodes to delegate to it. - */ -sealed interface CombinedClickableNode : PointerInputModifierNode { - /** - * Updates this node with new values, and resets any invalidated state accordingly. - * - * @param onClick will be called when user clicks on the element - * @param onLongClickLabel semantic / accessibility label for the [onLongClick] action - * @param onLongClick will be called when user long presses on the element - * @param onDoubleClick will be called when user double clicks on the element - * @param interactionSource [MutableInteractionSource] that will be used to emit - * [PressInteraction.Press] when this clickable is pressed. Only the initial (first) press - * will be recorded and emitted with [MutableInteractionSource]. If `null`, and there is an - * [indicationNodeFactory] provided, an internal [MutableInteractionSource] will be created - * when required. - * @param indicationNodeFactory the [IndicationNodeFactory] used to optionally render - * [Indication] inside this node, instead of using a separate [Modifier.indication]. This - * should be preferred for performance reasons over using [Modifier.indication] separately. - * @param enabled Controls the enabled state. When false, [onClick], [onLongClick] or - * [onDoubleClick] won't be invoked - * @param onClickLabel semantic / accessibility label for the [onClick] action - * @param role the type of user interface element. Accessibility services might use this to - * describe the element or do customizations - */ - fun update( - onClick: () -> Unit, - onLongClickLabel: String?, - onLongClick: (() -> Unit)?, - onDoubleClick: (() -> Unit)?, - interactionSource: MutableInteractionSource?, - indicationNodeFactory: IndicationNodeFactory?, - enabled: Boolean, - onClickLabel: String?, - role: Role? - ) -} - -private class CombinedClickableNodeImpl( +private class CombinedClickableNode( onClick: () -> Unit, private var onLongClickLabel: String?, private var onLongClick: (() -> Unit)?, @@ -801,7 +736,6 @@ private class CombinedClickableNodeImpl( onClickLabel: String?, role: Role?, ) : - CombinedClickableNode, CompositionLocalConsumerModifierNode, AbstractClickableNode( interactionSource, @@ -852,7 +786,7 @@ private class CombinedClickableNodeImpl( ) } - override fun update( + fun update( onClick: () -> Unit, onLongClickLabel: String?, onLongClick: (() -> Unit)?, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ClipScrollableContainer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ClipScrollableContainer.kt index 4dabbfe56adcc..cb9e9e025f190 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ClipScrollableContainer.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ClipScrollableContainer.kt @@ -38,9 +38,9 @@ import androidx.compose.ui.unit.dp fun Modifier.clipScrollableContainer(orientation: Orientation) = then( if (orientation == Orientation.Vertical) { - VerticalScrollableClipModifier + Modifier.clip(VerticalScrollableClipShape) } else { - HorizontalScrollableClipModifier + Modifier.clip(HorizontalScrollableClipShape) } ) @@ -61,44 +61,38 @@ fun Modifier.clipScrollableContainer(orientation: Orientation) = */ internal val MaxSupportedElevation = 30.dp -private val HorizontalScrollableClipModifier = - Modifier.clip( - object : Shape { - override fun createOutline( - size: Size, - layoutDirection: LayoutDirection, - density: Density - ): Outline { - val inflateSize = with(density) { MaxSupportedElevation.roundToPx().toFloat() } - return Outline.Rectangle( - Rect( - left = 0f, - top = -inflateSize, - right = size.width, - bottom = size.height + inflateSize - ) - ) - } - } - ) +internal object HorizontalScrollableClipShape : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val inflateSize = with(density) { MaxSupportedElevation.roundToPx().toFloat() } + return Outline.Rectangle( + Rect( + left = 0f, + top = -inflateSize, + right = size.width, + bottom = size.height + inflateSize + ) + ) + } +} -private val VerticalScrollableClipModifier = - Modifier.clip( - object : Shape { - override fun createOutline( - size: Size, - layoutDirection: LayoutDirection, - density: Density - ): Outline { - val inflateSize = with(density) { MaxSupportedElevation.roundToPx().toFloat() } - return Outline.Rectangle( - Rect( - left = -inflateSize, - top = 0f, - right = size.width + inflateSize, - bottom = size.height - ) - ) - } - } - ) +internal object VerticalScrollableClipShape : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val inflateSize = with(density) { MaxSupportedElevation.roundToPx().toFloat() } + return Outline.Rectangle( + Rect( + left = -inflateSize, + top = 0f, + right = size.width + inflateSize, + bottom = size.height + ) + ) + } +} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt index 51667ce2928bf..7cae901791859 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt @@ -59,7 +59,9 @@ object ComposeFoundationFlags { * Selecting flag to enable the change in Fling Propagation behavior in nested Scrollables. When * this is true, an ongoing fling that causes the scrollable container to hit the bounds will be * cancelled so the next scrollable in the chain can take over and fling with velocity left. We - * are doing a flagged roll out of this behavior change. + * are doing a flagged roll out of this behavior change. A node that is detached during a fling + * will be treated as a node that hit its bounds, that is, it will cancel its fling and + * propagate the remaining velocity through onPostFling. */ @Suppress("MutableBareField") @JvmField var NewNestedFlingPropagationEnabled = true @@ -81,14 +83,4 @@ object ComposeFoundationFlags { * continue the gesture until all pointers are up. */ @Suppress("MutableBareField") @JvmField var DragGesturePickUpEnabled = true - - /** - * Selecting flag to enable the Draggable fix for missing down events. This will influence the - * velocity generated after a Drag gesture. Because the down event is used by the velocity - * tracker to calculate the resulting velocity of a drag gesture, disabling this flag will incur - * in velocities being smaller than they're supposed to be. Enabling the flag will return the - * original behavior where velocities are on par with the ones generated by a similar gesture in - * the views framework version of the velocity tracker. - */ - @Suppress("MutableBareField") @JvmField var DraggableAddDownEventFixEnabled = true } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt index d2a3dc37dd8fa..03760b9e7a9c9 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt @@ -17,17 +17,28 @@ package androidx.compose.foundation import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalAccessorScope +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalWithComputedDefaultOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.unit.Velocity /** * An OverscrollEffect represents a visual effect that displays when the edges of a scrolling - * container have been reached with a scroll or fling. For the default platform effect that should - * be used in most cases, see - * [androidx.compose.foundation.gestures.ScrollableDefaults.overscrollEffect]. + * container have been reached with a scroll or fling. To create an instance of the default / + * currently provided [OverscrollFactory], use [rememberOverscrollEffect]. + * + * To implement, make sure to override [node] - this has a default implementation for compatibility + * reasons, but is required for an OverscrollEffect to render. * * OverscrollEffect conceptually 'decorates' scroll / fling events: consuming some of the delta or * velocity before and/or after the event is consumed by the scrolling container. [applyToScroll] @@ -112,44 +123,267 @@ interface OverscrollEffect { */ val isInProgress: Boolean - /** A [Modifier] that will draw this OverscrollEffect */ + /** + * A [Modifier] that will draw this OverscrollEffect + * + * This API is deprecated- implementers should instead override [node]. Callers should use + * [Modifier.overscroll]. + */ + @Deprecated( + "This has been replaced with `node`. If you are calling this property to render overscroll, use Modifier.overscroll() instead. If you are implementing OverscrollEffect, override `node` instead to render your overscroll.", + level = DeprecationLevel.ERROR, + replaceWith = + ReplaceWith("Modifier.overscroll(this)", "androidx.compose.foundation.overscroll") + ) val effectModifier: Modifier + get() = Modifier + + /** + * The [DelegatableNode] that will render this OverscrollEffect and provide any required size or + * other information to this effect. + * + * In most cases you should use [Modifier.overscroll] to render this OverscrollEffect, which + * will internally attach this node to the hierarchy. The node should be attached before + * [applyToScroll] or [applyToFling] is called to ensure correctness. + * + * This property should return a single instance, and can only be attached once, as with other + * [DelegatableNode]s. + */ + val node: DelegatableNode + get() = object : Modifier.Node() {} } /** - * Renders overscroll from the provided [overscrollEffect]. + * Returns a wrapped version of [this] [OverscrollEffect] with an empty [OverscrollEffect.node] that + * will not draw / render, but will still handle events. * - * This modifier is a convenience method to call [OverscrollEffect.effectModifier], which renders - * the actual effect. Note that this modifier is only responsible for the visual part of - * overscroll - on its own it will not handle input events. In addition to using this modifier you - * also need to propagate events to the [overscrollEffect], most commonly by using a - * [androidx.compose.foundation.gestures.scrollable]. + * This can be used along with [withoutEventHandling] in cases where you wish to change where + * overscroll is rendered for a given component. Pass this wrapped instance that doesn't render to + * the component that handles events (such as [androidx.compose.foundation.lazy.LazyColumn]) to + * prevent it from drawing the overscroll effect. Then to separately render the original overscroll + * effect, you can directly pass it to [Modifier.overscroll] (since that modifier only renders, and + * does not handle events). If instead you want to draw the overscroll in another component that + * handles events, such as a different lazy list, you need to first wrap the original overscroll + * effect with [withoutEventHandling] to prevent it from also dispatching events. * - * @sample androidx.compose.foundation.samples.OverscrollSample - * @param overscrollEffect the [OverscrollEffect] to render + * @sample androidx.compose.foundation.samples.OverscrollRenderedOnTopOfLazyListDecorations + * @see withoutEventHandling */ -fun Modifier.overscroll(overscrollEffect: OverscrollEffect): Modifier = - this.then(overscrollEffect.effectModifier) +@Stable +fun OverscrollEffect.withoutDrawing(): OverscrollEffect = + WrappedOverscrollEffect( + drawingEnabled = false, + eventHandlingEnabled = true, + innerOverscrollEffect = this + ) -@Composable internal expect fun rememberOverscrollEffect(): OverscrollEffect +/** + * Returns a wrapped version of [this] [OverscrollEffect] that will not handle events / consume + * values provided through [OverscrollEffect.applyToScroll] / [OverscrollEffect.applyToFling], but + * will still render / attach [OverscrollEffect.node]. + * + * This can be useful if you want to render an [OverscrollEffect] in a different component that + * normally provides events to overscroll, such as a [androidx.compose.foundation.lazy.LazyColumn]. + * Use this along with [withoutDrawing] to create two wrapped instances: one that does not handle + * events, and one that does not draw, so you can ensure that the overscroll effect is only rendered + * once, and only receives events from one source. + * + * @see withoutDrawing + */ +@Stable +fun OverscrollEffect.withoutEventHandling(): OverscrollEffect = + WrappedOverscrollEffect( + drawingEnabled = true, + eventHandlingEnabled = false, + innerOverscrollEffect = this + ) -internal object NoOpOverscrollEffect : OverscrollEffect { +@Immutable +private class WrappedOverscrollEffect( + private val drawingEnabled: Boolean, + private val eventHandlingEnabled: Boolean, + private val innerOverscrollEffect: OverscrollEffect +) : OverscrollEffect { override fun applyToScroll( delta: Offset, source: NestedScrollSource, performScroll: (Offset) -> Offset - ): Offset = performScroll(delta) + ): Offset { + return if (eventHandlingEnabled) { + innerOverscrollEffect.applyToScroll(delta, source, performScroll) + } else { + performScroll(delta) + } + } override suspend fun applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity ) { - performFling(velocity) + if (eventHandlingEnabled) { + innerOverscrollEffect.applyToFling(velocity, performFling) + } else { + performFling(velocity) + } } override val isInProgress: Boolean - get() = false + get() = innerOverscrollEffect.isInProgress - override val effectModifier: Modifier - get() = Modifier + override val node: DelegatableNode = + if (drawingEnabled) innerOverscrollEffect.node else object : Modifier.Node() {} + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WrappedOverscrollEffect) return false + + if (drawingEnabled != other.drawingEnabled) return false + if (eventHandlingEnabled != other.eventHandlingEnabled) return false + if (innerOverscrollEffect != other.innerOverscrollEffect) return false + + return true + } + + override fun hashCode(): Int { + var result = drawingEnabled.hashCode() + result = 31 * result + eventHandlingEnabled.hashCode() + result = 31 * result + innerOverscrollEffect.hashCode() + return result + } +} + +/** + * Renders overscroll from the provided [overscrollEffect]. + * + * This modifier attaches the provided [overscrollEffect]'s [OverscrollEffect.node] to the + * hierarchy, which renders the actual effect. Note that this modifier is only responsible for the + * visual part of overscroll - on its own it will not handle input events. In addition to using this + * modifier you also need to propagate events to the [overscrollEffect], most commonly by using a + * [androidx.compose.foundation.gestures.scrollable]. + * + * Alternatively, you can use a higher level API such as [verticalScroll] or + * [androidx.compose.foundation.lazy.LazyColumn] and provide a custom [OverscrollEffect] - these + * components will both render and provide events to the [OverscrollEffect], so you do not need to + * manually render the effect with this modifier. + * + * @sample androidx.compose.foundation.samples.OverscrollSample + * @param overscrollEffect the [OverscrollEffect] to render + */ +@Suppress("DEPRECATION_ERROR") +fun Modifier.overscroll(overscrollEffect: OverscrollEffect?): Modifier { + val effectModifier = overscrollEffect?.effectModifier ?: Modifier + val modifier = + if (effectModifier !== Modifier) effectModifier + else OverscrollModifierElement(overscrollEffect) + return this.then(modifier) +} + +private class OverscrollModifierElement( + private val overscrollEffect: OverscrollEffect?, +) : ModifierNodeElement() { + override fun create(): OverscrollModifierNode { + return OverscrollModifierNode(overscrollEffect?.node) + } + + override fun update(node: OverscrollModifierNode) { + node.update(overscrollEffect?.node) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is OverscrollModifierElement) return false + + if (overscrollEffect != other.overscrollEffect) return false + return true + } + + override fun hashCode(): Int { + return overscrollEffect.hashCode() + } + + override fun InspectorInfo.inspectableProperties() { + name = "overscroll" + properties["overscrollEffect"] = overscrollEffect + } +} + +private class OverscrollModifierNode(private var overscrollNode: DelegatableNode?) : + DelegatingNode() { + init { + attachIfNeeded() + } + + fun update(overscrollNode: DelegatableNode?) { + this.overscrollNode?.let { undelegate(it) } + this.overscrollNode = overscrollNode + attachIfNeeded() + } + + private fun attachIfNeeded() { + overscrollNode = + if (overscrollNode?.node?.isAttached == false) { + delegate(overscrollNode!!) + } else { + null + } + } +} + +/** + * Returns a remembered [OverscrollEffect] created from the current value of + * [LocalOverscrollFactory]. If [LocalOverscrollFactory] changes, a new [OverscrollEffect] will be + * returned. Returns `null` if `null` is provided to [LocalOverscrollFactory]. + */ +@Composable +fun rememberOverscrollEffect(): OverscrollEffect? { + val overscrollFactory = LocalOverscrollFactory.current ?: return null + return remember(overscrollFactory) { overscrollFactory.createOverscrollEffect() } } + +/** + * Needed for behavioral backwards compatibility for + * [androidx.compose.foundation.gestures.ScrollableDefaults.overscrollEffect]. New code should use + * [rememberOverscrollEffect] instead, which takes into account theme provided overscroll, rather + * than always using the platform default, without any customizations. + */ +@Composable internal expect fun rememberPlatformOverscrollEffect(): OverscrollEffect? + +/** + * A factory for creating [OverscrollEffect]s. You can provide a factory instance to + * [LocalOverscrollFactory] to globally change the factory, and hence effect, used by components + * within the hierarchy. + * + * See [rememberOverscrollEffect] to remember an [OverscrollEffect] from the current factory + * provided to [LocalOverscrollFactory]. + */ +interface OverscrollFactory { + /** Returns a new [OverscrollEffect] instance. */ + fun createOverscrollEffect(): OverscrollEffect + + /** + * Require hashCode() to be implemented. Using a data class is sufficient. Singletons and + * instances with no properties may implement this function by returning an arbitrary constant. + */ + override fun hashCode(): Int + + /** + * Require equals() to be implemented. Using a data class is sufficient. Singletons may + * implement this function with referential equality (`this === other`). Instances with no + * properties may implement this function by checking the type of the other object. + */ + override fun equals(other: Any?): Boolean +} + +/** + * CompositionLocal that provides an [OverscrollFactory] through the hierarchy. This will be used by + * default by scrolling components, so you can provide an [OverscrollFactory] here to override the + * overscroll used by components within a hierarchy. + * + * See [rememberOverscrollEffect] to remember an [OverscrollEffect] from the current provided value. + */ +val LocalOverscrollFactory: ProvidableCompositionLocal = + compositionLocalWithComputedDefaultOf { + defaultOverscrollFactory() + } + +internal expect fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt index 34391a9483d4b..0e25633405a21 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.SemanticsModifierNode import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.ScrollAxisRange import androidx.compose.ui.semantics.SemanticsPropertyReceiver @@ -194,6 +193,50 @@ class ScrollState(initial: Int) : ScrollableState { } } +/** + * Modify element to allow to scroll vertically when height of the content is bigger than max + * constraints allow. + * + * @sample androidx.compose.foundation.samples.VerticalScrollExample + * + * In order to use this modifier, you need to create and own [ScrollState] + * + * See the other overload in order to provide a custom [OverscrollEffect] + * + * @param state state of the scroll + * @param enabled whether or not scrolling via touch input is enabled + * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If + * `null`, default from [ScrollableDefaults.flingBehavior] will be used. + * @param reverseScrolling reverse the direction of scrolling, when `true`, 0 [ScrollState.value] + * will mean bottom, when `false`, 0 [ScrollState.value] will mean top + * @see [rememberScrollState] + */ +fun Modifier.verticalScroll( + state: ScrollState, + enabled: Boolean = true, + flingBehavior: FlingBehavior? = null, + reverseScrolling: Boolean = false +) = + composed( + factory = { + verticalScroll( + state = state, + enabled = enabled, + flingBehavior = flingBehavior, + reverseScrolling = reverseScrolling, + overscrollEffect = rememberOverscrollEffect(), + ) + }, + inspectorInfo = + debugInspectorInfo { + name = "verticalScroll" + properties["state"] = state + properties["enabled"] = enabled + properties["flingBehavior"] = flingBehavior + properties["reverseScrolling"] = reverseScrolling + } + ) + /** * Modify element to allow to scroll vertically when height of the content is bigger than max * constraints allow. @@ -203,6 +246,9 @@ class ScrollState(initial: Int) : ScrollableState { * In order to use this modifier, you need to create and own [ScrollState] * * @param state state of the scroll + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * modifier. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param enabled whether or not scrolling via touch input is enabled * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If * `null`, default from [ScrollableDefaults.flingBehavior] will be used. @@ -212,6 +258,7 @@ class ScrollState(initial: Int) : ScrollableState { */ fun Modifier.verticalScroll( state: ScrollState, + overscrollEffect: OverscrollEffect?, enabled: Boolean = true, flingBehavior: FlingBehavior? = null, reverseScrolling: Boolean = false @@ -221,7 +268,52 @@ fun Modifier.verticalScroll( isScrollable = enabled, reverseScrolling = reverseScrolling, flingBehavior = flingBehavior, - isVertical = true + isVertical = true, + overscrollEffect = overscrollEffect + ) + +/** + * Modify element to allow to scroll horizontally when width of the content is bigger than max + * constraints allow. + * + * @sample androidx.compose.foundation.samples.HorizontalScrollSample + * + * In order to use this modifier, you need to create and own [ScrollState] + * + * See the other overload in order to provide a custom [OverscrollEffect] + * + * @param state state of the scroll + * @param enabled whether or not scrolling via touch input is enabled + * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If + * `null`, default from [ScrollableDefaults.flingBehavior] will be used. + * @param reverseScrolling reverse the direction of scrolling, when `true`, 0 [ScrollState.value] + * will mean right, when `false`, 0 [ScrollState.value] will mean left + * @see [rememberScrollState] + */ +fun Modifier.horizontalScroll( + state: ScrollState, + enabled: Boolean = true, + flingBehavior: FlingBehavior? = null, + reverseScrolling: Boolean = false +) = + composed( + factory = { + horizontalScroll( + state = state, + enabled = enabled, + flingBehavior = flingBehavior, + reverseScrolling = reverseScrolling, + overscrollEffect = rememberOverscrollEffect(), + ) + }, + inspectorInfo = + debugInspectorInfo { + name = "horizontalScroll" + properties["state"] = state + properties["enabled"] = enabled + properties["flingBehavior"] = flingBehavior + properties["reverseScrolling"] = reverseScrolling + } ) /** @@ -233,6 +325,9 @@ fun Modifier.verticalScroll( * In order to use this modifier, you need to create and own [ScrollState] * * @param state state of the scroll + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * modifier. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param enabled whether or not scrolling via touch input is enabled * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If * `null`, default from [ScrollableDefaults.flingBehavior] will be used. @@ -242,6 +337,7 @@ fun Modifier.verticalScroll( */ fun Modifier.horizontalScroll( state: ScrollState, + overscrollEffect: OverscrollEffect?, enabled: Boolean = true, flingBehavior: FlingBehavior? = null, reverseScrolling: Boolean = false @@ -251,7 +347,8 @@ fun Modifier.horizontalScroll( isScrollable = enabled, reverseScrolling = reverseScrolling, flingBehavior = flingBehavior, - isVertical = false + isVertical = false, + overscrollEffect = overscrollEffect ) private fun Modifier.scroll( @@ -259,38 +356,21 @@ private fun Modifier.scroll( reverseScrolling: Boolean, flingBehavior: FlingBehavior?, isScrollable: Boolean, - isVertical: Boolean -) = - composed( - factory = { - val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal - val reverseDirection = - ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseScrolling - ) - Modifier.scrollingContainer( - state = state, - orientation = orientation, - enabled = isScrollable, - reverseDirection = reverseDirection, - flingBehavior = flingBehavior, - interactionSource = state.internalInteractionSource, - overscrollEffect = ScrollableDefaults.overscrollEffect() - ) - .then(ScrollingLayoutElement(state, reverseScrolling, isVertical)) - }, - inspectorInfo = - debugInspectorInfo { - name = "scroll" - properties["state"] = state - properties["reverseScrolling"] = reverseScrolling - properties["flingBehavior"] = flingBehavior - properties["isScrollable"] = isScrollable - properties["isVertical"] = isVertical - } - ) + isVertical: Boolean, + overscrollEffect: OverscrollEffect? +): Modifier { + val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal + return scrollingContainer( + state = state, + orientation = orientation, + enabled = isScrollable, + reverseScrolling = reverseScrolling, + flingBehavior = flingBehavior, + interactionSource = state.internalInteractionSource, + overscrollEffect = overscrollEffect + ) + .then(ScrollingLayoutElement(state, reverseScrolling, isVertical)) +} internal class ScrollingLayoutElement( val scrollState: ScrollState, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt index f0f3bbf4a2da4..33a3730b37f5a 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt @@ -19,33 +19,267 @@ package androidx.compose.foundation import androidx.compose.foundation.gestures.BringIntoViewSpec import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.ScrollableNode import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.invalidatePlacement +import androidx.compose.ui.node.requireLayoutDirection +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.LayoutDirection -// TODO b/316559454 to remove @Composable from it and make it public +// TODO b/316559454 to make it public /** Scrolling related information to transform a layout into a "Scrollable Container" */ internal fun Modifier.scrollingContainer( state: ScrollableState, orientation: Orientation, enabled: Boolean, - reverseDirection: Boolean, + reverseScrolling: Boolean, flingBehavior: FlingBehavior?, interactionSource: MutableInteractionSource?, - bringIntoViewSpec: BringIntoViewSpec? = null, - overscrollEffect: OverscrollEffect? = null + overscrollEffect: OverscrollEffect?, + bringIntoViewSpec: BringIntoViewSpec? = null ): Modifier { - return clipScrollableContainer(orientation) - .then(if (overscrollEffect == null) Modifier else Modifier.overscroll(overscrollEffect)) - .scrollable( + return this.then( + ScrollingContainerElement( + state = state, orientation = orientation, - reverseDirection = reverseDirection, enabled = enabled, + reverseScrolling = reverseScrolling, + flingBehavior = flingBehavior, interactionSource = interactionSource, + bringIntoViewSpec = bringIntoViewSpec, + overscrollEffect = overscrollEffect + ) + ) +} + +/** + * Applies clipping and wraps [androidx.compose.foundation.gestures.scrollable] and automatically + * calculates reverseDirection using [ScrollableDefaults.reverseDirection] based on the provided + * [orientation] and [reverseScrolling] parameters, and the resolved [LayoutDirection]. + */ +private class ScrollingContainerElement( + private val state: ScrollableState, + private val orientation: Orientation, + private val enabled: Boolean, + private val reverseScrolling: Boolean, + private val flingBehavior: FlingBehavior?, + private val interactionSource: MutableInteractionSource?, + private val bringIntoViewSpec: BringIntoViewSpec?, + private val overscrollEffect: OverscrollEffect? +) : ModifierNodeElement() { + override fun create(): ScrollingContainerNode { + return ScrollingContainerNode( + state = state, + orientation = orientation, + enabled = enabled, + reverseScrolling = reverseScrolling, flingBehavior = flingBehavior, + interactionSource = interactionSource, + bringIntoViewSpec = bringIntoViewSpec, + overscrollEffect = overscrollEffect + ) + } + + override fun update(node: ScrollingContainerNode) { + node.update( state = state, + orientation = orientation, overscrollEffect = overscrollEffect, + enabled = enabled, + reverseScrolling = reverseScrolling, + flingBehavior = flingBehavior, + interactionSource = interactionSource, bringIntoViewSpec = bringIntoViewSpec ) + } + + override fun InspectorInfo.inspectableProperties() { + name = "scrollingContainer" + properties["state"] = state + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["reverseScrolling"] = reverseScrolling + properties["flingBehavior"] = flingBehavior + properties["interactionSource"] = interactionSource + properties["bringIntoViewSpec"] = bringIntoViewSpec + properties["overscrollEffect"] = overscrollEffect + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as ScrollingContainerElement + + if (state != other.state) return false + if (orientation != other.orientation) return false + if (enabled != other.enabled) return false + if (reverseScrolling != other.reverseScrolling) return false + if (flingBehavior != other.flingBehavior) return false + if (interactionSource != other.interactionSource) return false + if (bringIntoViewSpec != other.bringIntoViewSpec) return false + if (overscrollEffect != other.overscrollEffect) return false + + return true + } + + override fun hashCode(): Int { + var result = state.hashCode() + result = 31 * result + orientation.hashCode() + result = 31 * result + enabled.hashCode() + result = 31 * result + reverseScrolling.hashCode() + result = 31 * result + (flingBehavior?.hashCode() ?: 0) + result = 31 * result + (interactionSource?.hashCode() ?: 0) + result = 31 * result + (bringIntoViewSpec?.hashCode() ?: 0) + result = 31 * result + (overscrollEffect?.hashCode() ?: 0) + return result + } +} + +private class ScrollingContainerNode( + private var state: ScrollableState, + private var orientation: Orientation, + private var enabled: Boolean, + private var reverseScrolling: Boolean, + private var flingBehavior: FlingBehavior?, + private var interactionSource: MutableInteractionSource?, + private var bringIntoViewSpec: BringIntoViewSpec?, + private var overscrollEffect: OverscrollEffect? +) : DelegatingNode(), LayoutModifierNode { + override val shouldAutoInvalidate = false + private var scrollableNode: ScrollableNode? = null + private var overscrollNode: DelegatableNode? = null + private var shouldReverseDirection = false + + // Needs to be mutated to properly update the underlying layer, which relies on instance + // equality + private var layerBlock: GraphicsLayerScope.() -> Unit = { + clip = true + shape = + if (orientation == Orientation.Vertical) VerticalScrollableClipShape + else HorizontalScrollableClipShape + } + + override fun onAttach() { + shouldReverseDirection = shouldReverseDirection() + if (scrollableNode == null) { + scrollableNode = + delegate( + ScrollableNode( + state, + overscrollEffect, + flingBehavior, + orientation, + enabled, + shouldReverseDirection, + interactionSource, + bringIntoViewSpec + ) + ) + } + attachOverscrollNodeIfNeeded() + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + val placeable = measurable.measure(constraints) + // Note: this is functionally the same as Modifier.clip, but inlined to reduce nodes. + return layout(placeable.width, placeable.height) { + placeable.placeWithLayer(0, 0, layerBlock = layerBlock) + } + } + + override fun onLayoutDirectionChange() { + val reverseDirection = shouldReverseDirection() + if (shouldReverseDirection != reverseDirection) { + shouldReverseDirection = reverseDirection + update( + state, + orientation, + overscrollEffect, + enabled, + reverseScrolling, + flingBehavior, + interactionSource, + bringIntoViewSpec + ) + } + } + + fun update( + state: ScrollableState, + orientation: Orientation, + overscrollEffect: OverscrollEffect?, + enabled: Boolean, + reverseScrolling: Boolean, + flingBehavior: FlingBehavior?, + interactionSource: MutableInteractionSource?, + bringIntoViewSpec: BringIntoViewSpec? + ) { + this.state = state + if (this.orientation != orientation) { + this.orientation = orientation + this.layerBlock = { + clip = true + shape = + if (orientation == Orientation.Vertical) VerticalScrollableClipShape + else HorizontalScrollableClipShape + } + invalidatePlacement() + } + if (this.overscrollEffect != overscrollEffect) { + this.overscrollEffect = overscrollEffect + overscrollNode?.let { undelegate(it) } + overscrollNode = null + attachOverscrollNodeIfNeeded() + } + this.enabled = enabled + this.reverseScrolling = reverseScrolling + this.flingBehavior = flingBehavior + this.interactionSource = interactionSource + this.bringIntoViewSpec = bringIntoViewSpec + this.shouldReverseDirection = shouldReverseDirection() + + scrollableNode?.update( + state, + orientation, + overscrollEffect, + enabled, + shouldReverseDirection, + flingBehavior, + interactionSource, + bringIntoViewSpec + ) + } + + fun shouldReverseDirection(): Boolean { + var layoutDirection = LayoutDirection.Ltr + if (isAttached) { + layoutDirection = requireLayoutDirection() + } + return ScrollableDefaults.reverseDirection(layoutDirection, orientation, reverseScrolling) + } + + private fun attachOverscrollNodeIfNeeded() { + if (overscrollNode == null && overscrollEffect != null) { + val node = overscrollEffect!!.node + if (!node.node.isAttached) { + overscrollNode = delegate(node) + } + } + } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt index 5ae58f60a966c..b303e466419b2 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt @@ -54,9 +54,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.node.ObserverModifierNode -import androidx.compose.ui.node.currentValueOf -import androidx.compose.ui.node.observeReads +import androidx.compose.ui.node.requireDensity import androidx.compose.ui.node.requireLayoutDirection import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalDensity @@ -365,11 +363,10 @@ private class AnchoredDraggableNode( enabled = enabled, interactionSource = interactionSource, orientationLock = orientation - ), - ObserverModifierNode { + ) { lateinit var resolvedFlingBehavior: FlingBehavior - lateinit var density: Density + private var density: Density? = null private val isReverseDirection: Boolean get() = @@ -384,9 +381,14 @@ private class AnchoredDraggableNode( updateFlingBehavior(flingBehavior) } - override fun onObservedReadsChanged() { - val newDensity = currentValueOf(LocalDensity) - if (density != newDensity) { + override fun onDensityChange() { + onCancelPointerInput() + if (isAttached) updateDensity() + } + + private fun updateDensity() { + val newDensity = requireDensity() + if (density == null || density != newDensity) { density = newDensity updateFlingBehavior(flingBehavior) } @@ -395,26 +397,24 @@ private class AnchoredDraggableNode( private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) { // Fall back to default fling behavior if the new fling behavior is null this.resolvedFlingBehavior = - if (newFlingBehavior == null) { - // Only register for LocalDensity snapshot updates if we are creating a decay - observeReads { density = currentValueOf(LocalDensity) } - anchoredDraggableFlingBehavior( + newFlingBehavior + ?: anchoredDraggableFlingBehavior( snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec, positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold, - density = density, + density = requireDensity().also { density = it }, state = state ) - } else newFlingBehavior } override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) { state.anchoredDrag { forEachDelta { dragDelta -> + val oneDirectionalDelta = dragDelta.delta.reverseIfNeeded().toFloat() if (overscrollEffect == null) { - dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().toFloat())) + dragTo(state.newOffsetForDelta(oneDirectionalDelta)) } else { overscrollEffect!!.applyToScroll( - delta = dragDelta.delta.reverseIfNeeded(), + delta = oneDirectionalDelta.toOffset(), source = NestedScrollSource.UserInput ) { deltaForDrag -> val dragOffset = state.newOffsetForDelta(deltaForDrag.toFloat()) @@ -432,12 +432,13 @@ private class AnchoredDraggableNode( override fun onDragStopped(velocity: Velocity) { if (!isAttached) return coroutineScope.launch { + val oneDirectionalVelocity = velocity.reverseIfNeeded().toFloat() if (overscrollEffect == null) { - fling(velocity.reverseIfNeeded().toFloat()) + fling(oneDirectionalVelocity) } else { - overscrollEffect!!.applyToFling(velocity = velocity.reverseIfNeeded()) { + overscrollEffect!!.applyToFling(velocity = oneDirectionalVelocity.toVelocity()) { availableVelocity -> - val consumed = fling(velocity.reverseIfNeeded().toFloat()) + val consumed = fling(availableVelocity.toFloat()) val currentOffset = state.requireOffset() val minAnchor = state.anchors.minPosition() val maxAnchor = state.anchors.maxPosition() @@ -699,6 +700,7 @@ interface AnchoredDragScope { * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. */ @Deprecated(ConfigurationMovedToModifier, level = DeprecationLevel.WARNING) +@Suppress("DEPRECATION") // confirmValueChange is deprecated fun AnchoredDraggableState( initialValue: T, positionalThreshold: (totalDistance: Float) -> Float, @@ -736,6 +738,7 @@ fun AnchoredDraggableState( * reached. */ @Deprecated(ConfigurationMovedToModifier, level = DeprecationLevel.WARNING) +@Suppress("DEPRECATION") // confirmValueChange is deprecated fun AnchoredDraggableState( initialValue: T, anchors: DraggableAnchors, @@ -767,13 +770,39 @@ fun AnchoredDraggableState( * change the state either immediately or by starting an animation. * * @param initialValue The initial value of the state. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. */ @Stable -class AnchoredDraggableState( - initialValue: T, - internal val confirmValueChange: (newValue: T) -> Boolean = { true } -) { +class AnchoredDraggableState(initialValue: T) { + /** + * Construct an [AnchoredDraggableState] instance with anchors. + * + * @param initialValue The initial value of the state. + * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. + */ + constructor( + initialValue: T, + anchors: DraggableAnchors, + ) : this(initialValue) { + this.anchors = anchors + trySnapTo(initialValue) + } + + /** + * Construct an [AnchoredDraggableState] instance with anchors. + * + * @param initialValue The initial value of the state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state + * change. + * @sample androidx.compose.foundation.samples.AnchoredDraggableDynamicAnchorsSample For an + * example of using dynamic anchors to replace confirmValueChange. + */ + @Deprecated(ConfirmValueChangeDeprecated, level = DeprecationLevel.WARNING) + constructor( + initialValue: T, + confirmValueChange: (newValue: T) -> Boolean + ) : this(initialValue) { + this.confirmValueChange = confirmValueChange + } /** * Construct an [AnchoredDraggableState] instance with anchors. @@ -782,7 +811,11 @@ class AnchoredDraggableState( * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. * @param confirmValueChange Optional callback invoked to confirm or veto a pending state * change. + * @sample androidx.compose.foundation.samples.AnchoredDraggableDynamicAnchorsSample For an + * example of using dynamic anchors to replace confirmValueChange. */ + @Deprecated(ConfirmValueChangeDeprecated, level = DeprecationLevel.WARNING) + @Suppress("DEPRECATION") constructor( initialValue: T, anchors: DraggableAnchors, @@ -792,6 +825,7 @@ class AnchoredDraggableState( trySnapTo(initialValue) } + internal var confirmValueChange: (newValue: T) -> Boolean = { true } internal lateinit var positionalThreshold: (totalDistance: Float) -> Float internal lateinit var velocityThreshold: () -> Float @Deprecated(ConfigurationMovedToModifier, level = DeprecationLevel.WARNING) @@ -1005,7 +1039,6 @@ class AnchoredDraggableState( val targetValue = anchors.computeTarget( currentOffset = requireOffset(), - currentValue = previousValue, velocity = velocity, positionalThreshold, velocityThreshold @@ -1207,6 +1240,15 @@ class AnchoredDraggableState( companion object { /** The default [Saver] implementation for [AnchoredDraggableState]. */ + fun Saver() = + Saver, T>( + save = { it.currentValue }, + restore = { AnchoredDraggableState(initialValue = it) } + ) + + /** The default [Saver] implementation for [AnchoredDraggableState]. */ + @Deprecated(ConfirmValueChangeDeprecated, level = DeprecationLevel.WARNING) + @Suppress("DEPRECATION") fun Saver(confirmValueChange: (T) -> Boolean = { true }) = Saver, T>( save = { it.currentValue }, @@ -1409,31 +1451,31 @@ suspend fun AnchoredDraggableState.animateToWithDecay( */ private fun DraggableAnchors.computeTarget( currentOffset: Float, - currentValue: T, velocity: Float, positionalThreshold: (totalDistance: Float) -> Float, velocityThreshold: () -> Float ): T { val currentAnchors = this - val currentAnchorPosition = currentAnchors.positionOf(currentValue) - val velocityThresholdPx = velocityThreshold() - return if (currentAnchorPosition == currentOffset || currentAnchorPosition.isNaN()) { - currentValue + require(!currentOffset.isNaN()) { "The offset provided to computeTarget must not be NaN." } + val velocitySign = sign(velocity) + val isMoving = velocitySign == 1.0f || velocitySign == 1.0f + val isMovingForward = isMoving && sign(velocity) > 0f + // When we're not moving, just pick the closest anchor and don't consider directionality + return if (!isMoving) { + currentAnchors.closestAnchor(currentOffset)!! + } else if (abs(velocity) >= abs(velocityThreshold())) { + currentAnchors.closestAnchor(currentOffset, searchUpwards = isMovingForward)!! } else { - if (abs(velocity) >= abs(velocityThresholdPx)) { - currentAnchors.closestAnchor(currentOffset, sign(velocity) > 0)!! - } else { - val neighborAnchor = - currentAnchors.closestAnchor( - currentOffset, - currentOffset - currentAnchorPosition > 0 - )!! - val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor) - val distance = abs(currentAnchorPosition - neighborAnchorPosition) - val relativeThreshold = abs(positionalThreshold(distance)) - val relativePosition = abs(currentAnchorPosition - currentOffset) - if (relativePosition <= relativeThreshold) currentValue else neighborAnchor - } + val left = currentAnchors.closestAnchor(currentOffset, false)!! + val leftAnchorPosition = currentAnchors.positionOf(left) + val right = currentAnchors.closestAnchor(currentOffset, true)!! + val rightAnchorPosition = currentAnchors.positionOf(right) + val distance = abs(leftAnchorPosition - rightAnchorPosition) + val relativeThreshold = abs(positionalThreshold(distance)) + val closestAnchorFromStart = + if (isMovingForward) leftAnchorPosition else rightAnchorPosition + val relativePosition = abs(closestAnchorFromStart - currentOffset) + if (relativePosition <= relativeThreshold) left else right } } @@ -1600,7 +1642,7 @@ private class DefaultDraggableAnchors( override fun toString() = buildString { append("DraggableAnchors(anchors={") for (i in 0 until size) { - append("${anchorAt(0)}=${positionAt(i)}") + append("${anchorAt(i)}=${positionAt(i)}") if (i < size - 1) { append(", ") } @@ -1623,6 +1665,11 @@ private const val StartDragImmediatelyDeprecated = "startDragImmediately has been removed " + "without replacement. Modifier.anchoredDraggable sets startDragImmediately to true by " + "default when animations are running." +private const val ConfirmValueChangeDeprecated = + "confirmValueChange is deprecated without replacement. Rather than relying on a callback to " + + "veto state changes, the anchor set should not include disallowed anchors. See " + + "androidx.compose.foundation.samples.AnchoredDraggableDynamicAnchorsSample for an " + + "example of using dynamic anchors over confirmValueChange." /** * Construct a [FlingBehavior] for use with [Modifier.anchoredDraggable]. @@ -1665,7 +1712,6 @@ private fun AnchoredDraggableLayoutInfoProvider( val target = state.anchors.computeTarget( currentOffset = currentOffset, - currentValue = state.currentValue, velocity = velocity, positionalThreshold = positionalThreshold, velocityThreshold = velocityThreshold diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt index 4157fda20b14f..81da0bea1123c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt @@ -25,7 +25,6 @@ package androidx.compose.foundation.gestures // functions public import androidx.compose.foundation.ComposeFoundationFlags.DragGesturePickUpEnabled -import androidx.compose.foundation.ComposeFoundationFlags.DraggableAddDownEventFixEnabled import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.isSpecified @@ -175,25 +174,14 @@ suspend fun PointerInputScope.detectDragGestures( onDragCancel: () -> Unit = {}, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit ) = - if (DraggableAddDownEventFixEnabled) { - detectDragGestures( - onDragStart = { _, slopTriggerChange, _ -> onDragStart(slopTriggerChange.position) }, - onDragEnd = { onDragEnd.invoke() }, - onDragCancel = onDragCancel, - shouldAwaitTouchSlop = { true }, - orientationLock = null, - onDrag = onDrag - ) - } else { - legacyDetectDragGestures( - onDragStart = { change, _ -> onDragStart(change.position) }, - onDragEnd = { onDragEnd.invoke() }, - onDragCancel = onDragCancel, - shouldAwaitTouchSlop = { true }, - orientationLock = null, - onDrag = onDrag - ) - } + detectDragGestures( + onDragStart = { _, slopTriggerChange, _ -> onDragStart(slopTriggerChange.position) }, + onDragEnd = { onDragEnd.invoke() }, + onDragCancel = onDragCancel, + shouldAwaitTouchSlop = { true }, + orientationLock = null, + onDrag = onDrag + ) /** * A Gesture detector that waits for pointer down and touch slop in the direction specified by @@ -330,67 +318,6 @@ internal suspend fun PointerInputScope.detectDragGestures( } } -internal suspend fun PointerInputScope.legacyDetectDragGestures( - onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit, - onDragEnd: (change: PointerInputChange) -> Unit, - onDragCancel: () -> Unit, - shouldAwaitTouchSlop: () -> Boolean, - orientationLock: Orientation?, - onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit -) { - var overSlop: Offset - - awaitEachGesture { - val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - val awaitTouchSlop = shouldAwaitTouchSlop() - - if (!awaitTouchSlop) { - initialDown.consume() - } - val down = awaitFirstDown(requireUnconsumed = false) - var drag: PointerInputChange? - var initialDelta = Offset.Zero - overSlop = Offset.Zero - - if (awaitTouchSlop) { - do { - drag = - awaitPointerSlopOrCancellation( - down.id, - down.type, - orientation = orientationLock - ) { change, over -> - change.consume() - overSlop = over - } - } while (drag != null && !drag.isConsumed) - initialDelta = overSlop - } else { - drag = initialDown - } - - if (drag != null) { - onDragStart.invoke(drag, initialDelta) - onDrag(drag, overSlop) - val upEvent = - drag( - pointerId = drag.id, - onDrag = { - onDrag(it, it.positionChange()) - it.consume() - }, - orientation = orientationLock, - motionConsumed = { it.isConsumed } - ) - if (upEvent == null) { - onDragCancel() - } else { - onDragEnd(upEvent) - } - } - } -} - /** * Gesture detector that waits for pointer down and long press, after which it calls [onDrag] for * each drag event. diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt index 7da2e3e6f8f4f..885b489507313 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt @@ -16,7 +16,6 @@ package androidx.compose.foundation.gestures -import androidx.compose.foundation.ComposeFoundationFlags.DraggableAddDownEventFixEnabled import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatorMutex @@ -40,17 +39,13 @@ import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange -import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode -import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.sign import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -364,7 +359,7 @@ internal abstract class DragGestureNode( enabled: Boolean, interactionSource: MutableInteractionSource?, private var orientationLock: Orientation? -) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode { +) : DelegatingNode(), PointerInputModifierNode { protected var canDrag = canDrag private set @@ -490,29 +485,9 @@ internal abstract class DragGestureNode( } } - val onLegacyDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit = - { startEvent, initialDelta -> - if (canDrag.invoke(startEvent)) { - if (!isListeningForEvents) { - if (channel == null) { - channel = Channel(capacity = Channel.UNLIMITED) - } - startListeningForEvents() - } - val overSlopOffset = initialDelta - val xSign = sign(startEvent.position.x) - val ySign = sign(startEvent.position.y) - val adjustedStart = - startEvent.position - - Offset(overSlopOffset.x * xSign, overSlopOffset.y * ySign) - - channel?.trySend(DragStarted(adjustedStart)) - } - } - val onDragEnd: (change: PointerInputChange) -> Unit = { upEvent -> velocityTracker.addPointerInputChange(upEvent) - val maximumVelocity = currentValueOf(LocalViewConfiguration).maximumFlingVelocity + val maximumVelocity = viewConfiguration.maximumFlingVelocity val velocity = velocityTracker.calculateVelocity(Velocity(maximumVelocity, maximumVelocity)) velocityTracker.resetTracking() @@ -531,25 +506,14 @@ internal abstract class DragGestureNode( coroutineScope { try { - if (DraggableAddDownEventFixEnabled) { - detectDragGestures( - orientationLock = orientationLock, - onDragStart = onDragStart, - onDragEnd = onDragEnd, - onDragCancel = onDragCancel, - shouldAwaitTouchSlop = shouldAwaitTouchSlop, - onDrag = onDrag - ) - } else { - legacyDetectDragGestures( - orientationLock = orientationLock, - onDragStart = onLegacyDragStart, - onDragEnd = onDragEnd, - onDragCancel = onDragCancel, - shouldAwaitTouchSlop = shouldAwaitTouchSlop, - onDrag = onDrag - ) - } + detectDragGestures( + orientationLock = orientationLock, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + shouldAwaitTouchSlop = shouldAwaitTouchSlop, + onDrag = onDrag + ) } catch (cancellation: CancellationException) { channel?.trySend(DragCancelled) if (!isActive) throw cancellation diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable2D.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable2D.kt index 23f34585e46d7..6c148a06ad195 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable2D.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable2D.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.unit.Velocity -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope /** @@ -303,9 +302,8 @@ internal class Draggable2DNode( ) } - private fun Velocity.reverseIfNeeded() = if (reverseDirection) this * -1f else this * 1f - - private fun Offset.reverseIfNeeded() = if (reverseDirection) this * -1f else this * 1f + @Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") + private inline fun Offset.reverseIfNeeded() = if (reverseDirection) -this else this } private class DefaultDraggable2DState(val onDelta: (Offset) -> Unit) : Draggable2DState { @@ -326,7 +324,5 @@ private class DefaultDraggable2DState(val onDelta: (Offset) -> Unit) : Draggable } } -private val NoOpOnDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {} private val NoOpOnDragStart: (startedPosition: Offset) -> Unit = {} -private val NoOpOnDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit = {} private val NoOpOnDragStop: (velocity: Velocity) -> Unit = {} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/MouseWheelScrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/MouseWheelScrollable.kt index 3c959749208d3..d9a21a47bbf1c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/MouseWheelScrollable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/MouseWheelScrollable.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -56,13 +56,14 @@ internal class MouseWheelScrollNode( private val onScrollStopped: suspend (velocity: Velocity) -> Unit, private var enabled: Boolean, ) : DelegatingNode(), CompositionLocalConsumerModifierNode { + + // Need to wait until onAttach to read the scroll config. Currently this is static, so we + // don't need to worry about observation / updating this over time. private lateinit var mouseWheelScrollConfig: ScrollConfig override fun onAttach() { mouseWheelScrollConfig = platformScrollConfig() - coroutineScope.launch { - receiveMouseWheelEvents() - } + coroutineScope.launch { receiveMouseWheelEvents() } } // Note that when `MouseWheelScrollNode` is used as a delegate of `ScrollableNode`, @@ -71,11 +72,14 @@ internal class MouseWheelScrollNode( // and `ScrollableNode` is already an instance of `PointerInputModifierNode`. // This is worked around by having `MouseWheelScrollNode` simply forward the corresponding calls // to pointerInputNode (hence its need to be `internal`). - internal val pointerInputNode = delegate(SuspendingPointerInputModifierNode { - if (enabled) { - mouseWheelInput() - } - }) + internal val pointerInputNode = + delegate( + SuspendingPointerInputModifierNode { + if (enabled) { + mouseWheelInput() + } + } + ) fun update( enabled: Boolean, @@ -90,13 +94,15 @@ internal class MouseWheelScrollNode( } } - private suspend fun PointerInputScope.mouseWheelInput() = awaitPointerEventScope { - while (coroutineScope.isActive) { - val event = awaitScrollEvent() - if (!event.isConsumed) { - val consumed = onMouseWheel(event) - if (consumed) { - event.consume() + private suspend fun PointerInputScope.mouseWheelInput() { + awaitPointerEventScope { + while (coroutineScope.isActive) { + val event = awaitScrollEvent() + if (!event.isConsumed) { + val consumed = onMouseWheel(event) + if (consumed) { + event.consume() + } } } } @@ -110,25 +116,30 @@ internal class MouseWheelScrollNode( return event } - private inline val PointerEvent.isConsumed: Boolean get() = changes.fastAny { it.isConsumed } - private inline fun PointerEvent.consume() = changes.fastForEach { it.consume() } + private inline val PointerEvent.isConsumed: Boolean + get() = changes.fastAny { it.isConsumed } + + private fun PointerEvent.consume() = changes.fastForEach { it.consume() } private data class MouseWheelScrollDelta( val value: Offset, val timeMillis: Long, val shouldApplyImmediately: Boolean ) { - operator fun plus(other: MouseWheelScrollDelta) = MouseWheelScrollDelta( - value = value + other.value, - - // Pick time from last one - timeMillis = maxOf(timeMillis, other.timeMillis), - - // Ignore [other.shouldApplyImmediately] to avoid false-positive [isPreciseWheelScroll] - // detection during animation - shouldApplyImmediately = shouldApplyImmediately - ) + operator fun plus(other: MouseWheelScrollDelta) = + MouseWheelScrollDelta( + value = value + other.value, + + // Pick time from last one + timeMillis = maxOf(timeMillis, other.timeMillis), + + // Ignore [other.shouldApplyImmediately] to avoid false-positive + // [isPreciseWheelScroll] + // detection during animation + shouldApplyImmediately = shouldApplyImmediately + ) } + private val channel = Channel(capacity = Channel.UNLIMITED) private var isScrolling = false @@ -150,36 +161,44 @@ internal class MouseWheelScrollNode( } private fun PointerInputScope.onMouseWheel(pointerEvent: PointerEvent): Boolean { - val scrollDelta = with(mouseWheelScrollConfig) { - calculateMouseWheelScroll(pointerEvent, size) - } + val scrollDelta = + with(mouseWheelScrollConfig) { calculateMouseWheelScroll(pointerEvent, size) } return if (scrollingLogic.canConsumeDelta(scrollDelta)) { - channel.trySend(MouseWheelScrollDelta( - value = scrollDelta, - timeMillis = pointerEvent.changes.first().uptimeMillis, - shouldApplyImmediately = !mouseWheelScrollConfig.isSmoothScrollingEnabled - - // In case of high-resolution wheel, such as a freely rotating wheel with - // no notches or trackpads, delta should apply immediately, without any delays. - || mouseWheelScrollConfig.isPreciseWheelScroll(pointerEvent) - )).isSuccess + channel + .trySend( + MouseWheelScrollDelta( + value = scrollDelta, + timeMillis = pointerEvent.changes.first().uptimeMillis, + shouldApplyImmediately = !mouseWheelScrollConfig.isSmoothScrollingEnabled + + // In case of high-resolution wheel, such as a freely rotating wheel + // with + // no notches or trackpads, delta should apply immediately, without any + // delays. + || mouseWheelScrollConfig.isPreciseWheelScroll(pointerEvent) + ) + ) + .isSuccess } else isScrolling } - private fun Channel.sumOrNull() = - untilNull { tryReceive().getOrNull() } - .toList() - .reduceOrNull { accumulator, it -> accumulator + it } + private fun Channel.sumOrNull(): MouseWheelScrollDelta? { + var sum: MouseWheelScrollDelta? = null + for (i in untilNull { tryReceive().getOrNull() }) { + sum = if (sum == null) i else sum + i + } + return sum + } /** - * Replacement of regular [Channel.receive] that schedules an invalidation each frame. - * It avoids entering an idle state while waiting for [ScrollProgressTimeout]. - * It's important for tests that attempt to trigger another scroll after a mouse wheel event. + * Replacement of regular [Channel.receive] that schedules an invalidation each frame. It avoids + * entering an idle state while waiting for [ScrollProgressTimeout]. It's important for tests + * that attempt to trigger another scroll after a mouse wheel event. */ private suspend fun Channel.busyReceive() = coroutineScope { val job = launch { while (coroutineContext.isActive) { - withFrameNanos { } + withFrameNanos {} } } try { @@ -189,13 +208,12 @@ internal class MouseWheelScrollNode( } } - private fun untilNull(builderAction: () -> E?) = sequence { - do { - val element = builderAction()?.also { - yield(it) - } - } while (element != null) - } + private fun untilNull(builderAction: () -> E?) = + sequence { + do { + val element = builderAction()?.also { yield(it) } + } while (element != null) + } private fun ScrollingLogic.canConsumeDelta(scrollDelta: Offset): Boolean { val delta = scrollDelta.reverseIfNeeded().toFloat() // Use only current axis @@ -209,6 +227,7 @@ internal class MouseWheelScrollNode( } private val velocityTracker = MouseWheelVelocityTracker() + private fun trackVelocity(scrollDelta: MouseWheelScrollDelta) { velocityTracker.addDelta(scrollDelta.timeMillis, scrollDelta.value) } @@ -240,22 +259,21 @@ internal class MouseWheelScrollNode( */ suspend fun waitNextScrollDelta(timeoutMillis: Long): Boolean { if (timeoutMillis < 0) return false - return withTimeoutOrNull(timeoutMillis) { - channel.busyReceive() - }?.let { - // Keep this value unchanged during animation - // Currently, [isPreciseWheelScroll] might be unstable in case if - // a precise value is almost equal regular one. - val previousDeltaShouldApplyImmediately = targetScrollDelta.shouldApplyImmediately - targetScrollDelta = it.copy( - shouldApplyImmediately = previousDeltaShouldApplyImmediately - ) - targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat() - animationState = AnimationState(0f) // Reset previous animation leftover - trackVelocity(it) - - !targetValue.isLowScrollingDelta() - } ?: false + return withTimeoutOrNull(timeoutMillis) { channel.busyReceive() } + ?.let { + // Keep this value unchanged during animation + // Currently, [isPreciseWheelScroll] might be unstable in case if + // a precise value is almost equal regular one. + val previousDeltaShouldApplyImmediately = + targetScrollDelta.shouldApplyImmediately + targetScrollDelta = + it.copy(shouldApplyImmediately = previousDeltaShouldApplyImmediately) + targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat() + animationState = AnimationState(0f) // Reset previous animation leftover + trackVelocity(it) + + !targetValue.isLowScrollingDelta() + } ?: false } userScroll { @@ -263,7 +281,9 @@ internal class MouseWheelScrollNode( while (requiredAnimation) { requiredAnimation = false val targetValueLeftover = targetValue - animationState.value - if (targetScrollDelta.shouldApplyImmediately || abs(targetValueLeftover) < threshold) { + if ( + targetScrollDelta.shouldApplyImmediately || abs(targetValueLeftover) < threshold + ) { dispatchMouseWheelScroll(targetValueLeftover) requiredAnimation = waitNextScrollDelta(ScrollProgressTimeout) } else { @@ -271,12 +291,15 @@ internal class MouseWheelScrollNode( // so apply threshold immediately to avoid delays. val instantDelta = sign(targetValueLeftover) * threshold dispatchMouseWheelScroll(instantDelta) - animationState = animationState.copy(value = animationState.value + instantDelta) - - val durationMillis = (abs(targetValue - animationState.value) / speed) - .roundToInt() - .coerceAtMost(MaxAnimationDuration) - animateMouseWheelScroll(animationState, targetValue, durationMillis) { lastValue -> + animationState = + animationState.copy(value = animationState.value + instantDelta) + + val durationMillis = + (abs(targetValue - animationState.value) / speed) + .roundToInt() + .coerceAtMost(MaxAnimationDuration) + animateMouseWheelScroll(animationState, targetValue, durationMillis) { lastValue + -> // Sum delta from all pending events to avoid multiple animation restarts. val nextScrollDelta = channel.sumOrNull() if (nextScrollDelta != null) { @@ -289,8 +312,10 @@ internal class MouseWheelScrollNode( nextScrollDelta != null } if (!requiredAnimation) { - // If it's completed, wait the next event with timeout before resetting progress flag - requiredAnimation = waitNextScrollDelta(ScrollProgressTimeout - durationMillis) + // If it's completed, wait the next event with timeout before resetting + // progress flag + requiredAnimation = + waitNextScrollDelta(ScrollProgressTimeout - durationMillis) } } } @@ -314,10 +339,7 @@ internal class MouseWheelScrollNode( var lastValue = animationState.value animationState.animateTo( targetValue, - animationSpec = tween( - durationMillis = durationMillis, - easing = LinearEasing - ), + animationSpec = tween(durationMillis = durationMillis, easing = LinearEasing), sequentialAnimation = true ) { val delta = value - lastValue @@ -335,14 +357,16 @@ internal class MouseWheelScrollNode( } } - private fun NestedScrollScope.dispatchMouseWheelScroll(delta: Float) = with(scrollingLogic) { - val offset = delta.reverseIfNeeded().toOffset() - val consumed = scrollBy( - offset, - NestedScrollSource.UserInput, - ) - consumed.reverseIfNeeded().toFloat() - } + private fun NestedScrollScope.dispatchMouseWheelScroll(delta: Float) = + with(scrollingLogic) { + val offset = delta.reverseIfNeeded().toOffset() + val consumed = + scrollBy( + offset, + NestedScrollSource.UserInput, + ) + consumed.reverseIfNeeded().toFloat() + } } private class MouseWheelVelocityTracker { @@ -365,7 +389,7 @@ private class MouseWheelVelocityTracker { * Returns true, if the value is too low for visible change in scroll (consumed delta, animation-based change, etc), * false otherwise */ -private inline fun Float.isLowScrollingDelta(): Boolean = abs(this) < 0.5f +private fun Float.isLowScrollingDelta(): Boolean = isNaN() || abs(this) < 0.5f private val AnimationThreshold = 6.dp // (AnimationSpeed * MaxAnimationDuration) / (1000ms / 60Hz) private val AnimationSpeed = 1.dp // dp / ms diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt index 9f71567f75321..2b6f34f3028c8 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt @@ -24,6 +24,7 @@ import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.ComposeFoundationFlags.NewNestedFlingPropagationEnabled import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.FocusedBoundsObserverNode +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.PlatformOptimizedCancellationException @@ -32,6 +33,7 @@ import androidx.compose.foundation.gestures.Orientation.Vertical import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.relocation.BringIntoViewResponderNode import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.foundation.rememberPlatformOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier @@ -54,18 +56,17 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.UserI import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.SemanticsModifierNode import androidx.compose.ui.node.TraversableNode -import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.invalidateSemantics -import androidx.compose.ui.node.observeReads +import androidx.compose.ui.node.requireDensity import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.scrollBy @@ -76,6 +77,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.Velocity import androidx.compose.ui.util.fastAny import kotlin.math.abs +import kotlin.math.absoluteValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -133,8 +135,8 @@ fun Modifier.scrollable( * draggable, consider using [draggable]. * * This overload provides the access to [OverscrollEffect] that defines the behaviour of the over - * scrolling logic. Consider using [ScrollableDefaults.overscrollEffect] for the platform - * look-and-feel. + * scrolling logic. Use [androidx.compose.foundation.rememberOverscrollEffect] to create an instance + * of the current provided overscroll implementation. * * @sample androidx.compose.foundation.samples.ScrollableSample * @param state [ScrollableState] state of the scrollable. Defines how scroll events will be @@ -256,7 +258,7 @@ private class ScrollableElement( } } -private class ScrollableNode( +internal class ScrollableNode( state: ScrollableState, private var overscrollEffect: OverscrollEffect?, private var flingBehavior: FlingBehavior?, @@ -272,8 +274,6 @@ private class ScrollableNode( interactionSource = interactionSource, orientationLock = orientation ), - ObserverModifierNode, - CompositionLocalConsumerModifierNode, KeyInputModifierNode, SemanticsModifierNode { @@ -294,16 +294,7 @@ private class ScrollableNode( reverseDirection = reverseDirection, flingBehavior = flingBehavior ?: defaultFlingBehavior, nestedScrollDispatcher = nestedScrollDispatcher, - shouldCancelFling = { flingPixels -> - // fling should be cancelled if we try to scroll more than we can or if this node - // is detached during a fling. - // tries to scroll forward but cannot. - (flingPixels > 0.0f && !state.canScrollForward) || - // tries to scroll backward but cannot. - (flingPixels < 0.0f && !state.canScrollBackward) || - // node is detached. - !isAttached - } + isScrollableNodeAttached = { isAttached } ) private val nestedScrollConnection = @@ -314,12 +305,11 @@ private class ScrollableNode( ContentInViewNode(orientation, scrollingLogic, reverseDirection, bringIntoViewSpec) ) - // Need to wait until onAttach to read the scroll config. Currently this is static, so we - // don't need to worry about observation / updating this over time. - private var scrollConfig: ScrollConfig? = null private var scrollByAction: ((x: Float, y: Float) -> Boolean)? = null private var scrollByOffsetAction: (suspend (Offset) -> Offset)? = null + private var mouseWheelScrollNode: MouseWheelScrollNode? = null + init { /** Nested scrolling */ delegate(nestedScrollModifierNode(nestedScrollConnection, nestedScrollDispatcher)) @@ -344,30 +334,33 @@ private class ScrollableNode( override fun onDragStarted(startedPosition: Offset) {} - @OptIn(ExperimentalFoundationApi::class) override fun onDragStopped(velocity: Velocity) { nestedScrollDispatcher.coroutineScope.launch { scrollingLogic.onScrollStopped(velocity, isMouseWheel = false) } } - override fun startDragImmediately(): Boolean { - return scrollingLogic.shouldScrollImmediately() - } - - private val onWheelScrollStopped: suspend (velocity: Velocity) -> Unit = { velocity -> + private fun onWheelScrollStopped(velocity: Velocity) { nestedScrollDispatcher.coroutineScope.launch { scrollingLogic.onScrollStopped(velocity, isMouseWheel = true) } } - val mouseWheelScrollNode = delegate( - MouseWheelScrollNode( - scrollingLogic = scrollingLogic, - onScrollStopped = onWheelScrollStopped, - enabled = enabled, - ) - ) + override fun startDragImmediately(): Boolean { + return scrollingLogic.shouldScrollImmediately() + } + + private fun ensureMouseWheelScrollNodeInitialized() { + if (mouseWheelScrollNode != null) return + mouseWheelScrollNode = + delegate( + MouseWheelScrollNode( + scrollingLogic = scrollingLogic, + onScrollStopped = ::onWheelScrollStopped, + enabled = enabled, + ) + ) + } fun update( state: ScrollableState, @@ -397,12 +390,8 @@ private class ScrollableNode( flingBehavior = resolvedFlingBehavior, nestedScrollDispatcher = nestedScrollDispatcher ) - contentInViewNode.update(orientation, reverseDirection, bringIntoViewSpec) - - mouseWheelScrollNode.update( - enabled = enabled - ) + mouseWheelScrollNode?.update(enabled = enabled) this.overscrollEffect = overscrollEffect this.flingBehavior = flingBehavior @@ -424,22 +413,18 @@ private class ScrollableNode( override fun onAttach() { updateDefaultFlingBehavior() - scrollConfig = platformScrollConfig() } - // TODO(https://youtrack.jetbrains.com/issue/COMPOSE-731/Scrollable-doesnt-react-on-density-changes) - // it isn't called, because LocalDensity is staticCompositionLocalOf - override fun onObservedReadsChanged() { - // if density changes, update the default fling behavior. - updateDefaultFlingBehavior() + private fun updateDefaultFlingBehavior() { + if (!isAttached) return + val density = requireDensity() + defaultFlingBehavior.updateDensity(density) } - private fun updateDefaultFlingBehavior() { - // monitor change in Density - observeReads { - val density = currentValueOf(LocalDensity) - defaultFlingBehavior.updateDensity(density) - } + override fun onDensityChange() { + onCancelPointerInput() + updateDefaultFlingBehavior() + mouseWheelScrollNode?.pointerInputNode?.onDensityChange() } // Key handler for Page up/down scrolling behavior. @@ -506,7 +491,10 @@ private class ScrollableNode( if (pointerEvent.changes.fastAny { canDrag.invoke(it) }) { super.onPointerEvent(pointerEvent, pass, bounds) } - mouseWheelScrollNode.pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) + if (pass == PointerEventPass.Initial && pointerEvent.type == PointerEventType.Scroll) { + ensureMouseWheelScrollNodeInitialized() + } + mouseWheelScrollNode?.pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds) } override fun SemanticsPropertyReceiver.applySemantics() { @@ -535,17 +523,12 @@ private class ScrollableNode( override fun onCancelPointerInput() { super.onCancelPointerInput() - mouseWheelScrollNode.pointerInputNode.onCancelPointerInput() - } - - override fun onDensityChange() { - onCancelPointerInput() - mouseWheelScrollNode.pointerInputNode.onDensityChange() + mouseWheelScrollNode?.pointerInputNode?.onCancelPointerInput() } override fun onViewConfigurationChange() { super.onViewConfigurationChange() - mouseWheelScrollNode.pointerInputNode.onViewConfigurationChange() + mouseWheelScrollNode?.pointerInputNode?.onViewConfigurationChange() } } @@ -553,16 +536,48 @@ private class ScrollableNode( object ScrollableDefaults { /** Create and remember default [FlingBehavior] that will represent natural fling curve. */ + // TODO: It should differ between platforms, move it under expect/actual @Composable fun flingBehavior(): FlingBehavior = rememberPlatformDefaultFlingBehavior() /** - * Create and remember default [OverscrollEffect] that will be used for showing over scroll - * effects. + * Returns a remembered [OverscrollEffect] created from the current value of + * [LocalOverscrollFactory]. + * + * This API has been deprpecated, and replaced with [rememberOverscrollEffect] */ + @Deprecated( + "This API has been replaced with rememberOverscrollEffect, which queries theme provided OverscrollFactory values instead of the 'platform default' without customization.", + replaceWith = + ReplaceWith( + "rememberOverscrollEffect()", + "androidx.compose.foundation.rememberOverscrollEffect" + ) + ) @Composable fun overscrollEffect(): OverscrollEffect { - return rememberOverscrollEffect() + return rememberPlatformOverscrollEffect() ?: NoOpOverscrollEffect + } + + internal object NoOpOverscrollEffect : OverscrollEffect { + override fun applyToScroll( + delta: Offset, + source: NestedScrollSource, + performScroll: (Offset) -> Offset + ): Offset = performScroll(delta) + + override suspend fun applyToFling( + velocity: Velocity, + performFling: suspend (Velocity) -> Velocity + ) { + performFling(velocity) + } + + override val isInProgress: Boolean + get() = false + + override val node: DelegatableNode + get() = object : Modifier.Node() {} } /** @@ -593,9 +608,7 @@ object ScrollableDefaults { internal interface ScrollConfig { - /** - * Enables animated transition of scroll on mouse wheel events. - */ + /** Enables animated transition of scroll on mouse wheel events. */ val isSmoothScrollingEnabled: Boolean get() = true @@ -606,6 +619,7 @@ internal interface ScrollConfig { internal expect fun CompositionLocalConsumerModifierNode.platformScrollConfig(): ScrollConfig +// TODO: provide public way to drag by mouse (especially requested for Pager) private val CanDragCalculation: (PointerInputChange) -> Boolean = { change -> change.type != PointerType.Mouse } @@ -618,13 +632,16 @@ private val NoOpOnDragStarted: suspend CoroutineScope.(startedPosition: Offset) */ internal class ScrollingLogic( var scrollableState: ScrollableState, - private var orientation: Orientation, private var overscrollEffect: OverscrollEffect?, private var flingBehavior: FlingBehavior, + private var orientation: Orientation, private var reverseDirection: Boolean, private var nestedScrollDispatcher: NestedScrollDispatcher, - private val shouldCancelFling: (Float) -> Boolean + private val isScrollableNodeAttached: () -> Boolean ) { + // specifies if this scrollable node is currently flinging + var isFlinging = false + private set fun Float.toOffset(): Offset = when { @@ -638,11 +655,12 @@ internal class ScrollingLogic( fun Offset.toFloat(): Float = if (orientation == Horizontal) this.x else this.y - fun Float.toVelocity(): Velocity = when { - this == 0f -> Velocity.Zero - orientation == Horizontal -> Velocity(this, 0f) - else -> Velocity(0f, this) - } + fun Float.toVelocity(): Velocity = + when { + this == 0f -> Velocity.Zero + orientation == Horizontal -> Velocity(this, 0f) + else -> Velocity(0f, this) + } private fun Velocity.toFloat(): Float = if (orientation == Horizontal) this.x else this.y @@ -716,17 +734,14 @@ internal class ScrollingLogic( } } - fun dispatchRawDelta(scroll: Offset): Offset { + private fun dispatchRawDelta(scroll: Offset): Offset { return scrollableState .dispatchRawDelta(scroll.toFloat().reverseIfNeeded()) .reverseIfNeeded() .toOffset() } - suspend fun onScrollStopped( - initialVelocity: Velocity, - isMouseWheel: Boolean - ) { + suspend fun onScrollStopped(initialVelocity: Velocity, isMouseWheel: Boolean) { if (isMouseWheel && !flingBehavior.shouldBeTriggeredByMouseWheel) { return } @@ -750,9 +765,22 @@ internal class ScrollingLogic( } } + // fling should be cancelled if we try to scroll more than we can or if this node + // is detached during a fling. + private fun shouldCancelFling(pixels: Float): Boolean { + // tries to scroll forward but cannot. + return (pixels > 0.0f && !scrollableState.canScrollForward) || + // tries to scroll backward but cannot. + (pixels < 0.0f && !scrollableState.canScrollBackward) || + // node is detached. + !isScrollableNodeAttached.invoke() + } + + @OptIn(ExperimentalFoundationApi::class) suspend fun doFlingAnimation(available: Velocity): Velocity { var result: Velocity = available - scroll { + isFlinging = true + scroll(scrollPriority = MutatePriority.Default) { val nestedScrollScope = this val reverseScope = object : ScrollScope { @@ -763,7 +791,11 @@ internal class ScrollingLogic( // with the leftover velocity from the fling animation. Any nested scroll // node above will be able to pick up the left over velocity and continue // the fling. - if (NewNestedFlingPropagationEnabled && shouldCancelFling(pixels)) { + if ( + NewNestedFlingPropagationEnabled && + pixels.absoluteValue != 0.0f && + shouldCancelFling(pixels) + ) { throw FlingCancellationException() } @@ -785,6 +817,7 @@ internal class ScrollingLogic( } } } + isFlinging = false return result } @@ -810,7 +843,7 @@ internal class ScrollingLogic( overscrollEffect: OverscrollEffect?, reverseDirection: Boolean, flingBehavior: FlingBehavior, - nestedScrollDispatcher: NestedScrollDispatcher, + nestedScrollDispatcher: NestedScrollDispatcher ): Boolean { var resetPointerInputHandling = false if (this.scrollableState != scrollableState) { @@ -855,9 +888,19 @@ private class ScrollableNestedScrollConnection( Offset.Zero } + @OptIn(ExperimentalFoundationApi::class) override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { return if (enabled) { - val velocityLeft = scrollingLogic.doFlingAnimation(available) + val velocityLeft = + if (NewNestedFlingPropagationEnabled) { + if (scrollingLogic.isFlinging) { + Velocity.Zero + } else { + scrollingLogic.doFlingAnimation(available) + } + } else { + scrollingLogic.doFlingAnimation(available) + } available - velocityLeft } else { Velocity.Zero @@ -865,12 +908,11 @@ private class ScrollableNestedScrollConnection( } } -/** - * Compatibility interface for default fling behaviors that depends on [Density]. - */ +/** Compatibility interface for default fling behaviors that depends on [Density]. */ internal interface ScrollableDefaultFlingBehavior : FlingBehavior { /** - * Update the internal parameters of FlingBehavior in accordance with the new [androidx.compose.ui.unit.Density] value. + * Update the internal parameters of FlingBehavior in accordance with the new + * [androidx.compose.ui.unit.Density] value. * * @param density new density value. */ @@ -878,11 +920,11 @@ internal interface ScrollableDefaultFlingBehavior : FlingBehavior { } /** - * TODO Move it to public interface - * Currently, default [FlingBehavior] is not triggered at all to avoid unexpected effects - * during regular scrolling. However, custom one must be triggered because it's used not - * only for "inertia", but also for snapping in [androidx.compose.foundation.pager.Pager] or - * [androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior]. + * TODO: Move it to public interface Currently, default [FlingBehavior] is not triggered at all to + * avoid unexpected effects during regular scrolling. However, custom one must be triggered + * because it's used not only for "inertia", but also for snapping in + * [androidx.compose.foundation.pager.Pager] or + * [androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior]. */ private val FlingBehavior.shouldBeTriggeredByMouseWheel get() = this !is ScrollableDefaultFlingBehavior @@ -899,7 +941,7 @@ internal expect fun platformDefaultFlingBehavior(): ScrollableDefaultFlingBehavi internal expect fun rememberPlatformDefaultFlingBehavior(): FlingBehavior internal class DefaultFlingBehavior( - var flingDecay: DecayAnimationSpec, + private var flingDecay: DecayAnimationSpec, private val motionDurationScale: MotionDurationScale = DefaultScrollMotionDurationScale ) : ScrollableDefaultFlingBehavior { diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Transformable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Transformable.kt index f05f42e718a7b..319db744e161c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Transformable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Transformable.kt @@ -23,20 +23,31 @@ import androidx.compose.foundation.gestures.TransformEvent.TransformStopped import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.input.pointer.isCtrlPressed import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach import kotlin.math.PI import kotlin.math.abs +import kotlin.math.pow import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable.isActive import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -142,11 +153,18 @@ private class TransformableNode( private var canPan: (Offset) -> Boolean, private var lockRotationOnZoomPan: Boolean, private var enabled: Boolean -) : DelegatingNode() { +) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode { private val updatedCanPan: (Offset) -> Boolean = { canPan.invoke(it) } private val channel = Channel(capacity = Channel.UNLIMITED) + private var scrollConfig: ScrollConfig? = null + + override fun onAttach() { + super.onAttach() + scrollConfig = platformScrollConfig() + } + private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { @@ -174,6 +192,7 @@ private class TransformableNode( } } } + awaitEachGesture { try { detectZoom(lockRotationOnZoomPan, channel, updatedCanPan) @@ -187,6 +206,8 @@ private class TransformableNode( } ) + private var pointerInputModifierMouse: PointerInputModifierNode? = null + fun update( state: TransformableState, canPan: (Offset) -> Boolean, @@ -205,6 +226,101 @@ private class TransformableNode( pointerInputNode.resetPointerInputHandler() } } + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) { + val scrollConfig = scrollConfig + if ( + enabled && + pointerEvent.changes.fastAny { it.type == PointerType.Mouse } && + scrollConfig != null && + pointerInputModifierMouse == null + ) { + pointerInputModifierMouse = + delegate( + SuspendingPointerInputModifierNode { + detectZoomByCtrlMouseScroll(channel, scrollConfig) + } + ) + } + pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) + pointerInputModifierMouse?.onPointerEvent(pointerEvent, pass, bounds) + } + + override fun onCancelPointerInput() { + pointerInputNode.onCancelPointerInput() + pointerInputModifierMouse?.onCancelPointerInput() + } +} + +// The factor used to covert the mouse scroll to zoom. +// Every 545 pixels of scroll is converted into 2 times zoom. This value is calculated from +// curve fitting the ChromeOS's zoom factors. +internal const val SCROLL_FACTOR = 545f + +private suspend fun PointerInputScope.detectZoomByCtrlMouseScroll( + channel: Channel, + scrollConfig: ScrollConfig +) { + val currentContext = currentCoroutineContext() + awaitPointerEventScope { + while (currentContext.isActive) { + try { + var scrollDelta = awaitFirstCtrlMouseScroll(scrollConfig) + channel.trySend(TransformStarted) + while (true) { + // This formula is curve fitting form Chrome OS's ctrl + scroll implementation. + val zoomChange = 2f.pow(scrollDelta.y / SCROLL_FACTOR) + channel.trySend( + TransformDelta( + zoomChange = zoomChange, + panChange = Offset.Zero, + rotationChange = 0f + ) + ) + scrollDelta = awaitCtrlMouseScrollOrNull(scrollConfig) ?: break + } + } finally { + channel.trySend(TransformStopped) + } + } + } +} + +/** Await for the first mouse scroll event while ctrl is pressed and return its scrollDelta. */ +private suspend fun AwaitPointerEventScope.awaitFirstCtrlMouseScroll( + scrollConfig: ScrollConfig +): Offset { + var offset: Offset? + do { + offset = awaitCtrlMouseScrollOrNull(scrollConfig) + } while (offset == null) + return offset +} + +/** + * Await for the next pointer event. If the PointerEvent is a mouse scroll event that has non zero + * scrollDelta and the ctrl key is pressed, its scrollDelta is returned. Otherwise, null is + * returned. The event is consumed when it detects ctrl + mouse scroll. + */ +private suspend fun AwaitPointerEventScope.awaitCtrlMouseScrollOrNull( + scrollConfig: ScrollConfig +): Offset? { + val pointer = awaitPointerEvent() + if (!pointer.keyboardModifiers.isCtrlPressed || pointer.type != PointerEventType.Scroll) { + return null + } + val scrollDelta = with(scrollConfig) { calculateMouseWheelScroll(pointer, size) } + + if (scrollDelta == Offset.Zero) { + return null + } + + pointer.changes.fastForEach { it.consume() } + return scrollDelta } private suspend fun AwaitPointerEventScope.detectZoom( diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/interaction/InteractionSource.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/interaction/InteractionSource.kt index 7c8bc4f660ef3..d4e9a060c3500 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/interaction/InteractionSource.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/interaction/InteractionSource.kt @@ -19,10 +19,10 @@ package androidx.compose.foundation.interaction import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.remember +import kotlin.js.JsName import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlin.js.JsName /** * InteractionSource represents a stream of [Interaction]s corresponding to events emitted by a diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt index d7b2e5df30f51..18dac10f95085 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt @@ -16,11 +16,13 @@ package androidx.compose.foundation.lazy +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.internal.JvmDefaultWithCompatibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -314,6 +316,9 @@ inline fun LazyListScope.itemsIndexed( * @param flingBehavior logic describing fling behavior. * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions is * allowed. You can still scroll programmatically using the state even when it is disabled. + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * layout. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param content a block which describes the content. Inside this block you can use methods like * [LazyListScope.item] to add a single item or [LazyListScope.items] to add a list of items. */ @@ -328,6 +333,7 @@ fun LazyRow( verticalAlignment: Alignment.Vertical = Alignment.Top, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyListScope.() -> Unit ) { LazyList( @@ -340,6 +346,7 @@ fun LazyRow( flingBehavior = flingBehavior, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, content = content ) } @@ -369,6 +376,9 @@ fun LazyRow( * @param flingBehavior logic describing fling behavior. * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions is * allowed. You can still scroll programmatically using the state even when it is disabled + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * layout. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param content a block which describes the content. Inside this block you can use methods like * [LazyListScope.item] to add a single item or [LazyListScope.items] to add a list of items. */ @@ -383,6 +393,7 @@ fun LazyColumn( horizontalAlignment: Alignment.Horizontal = Alignment.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyListScope.() -> Unit ) { LazyList( @@ -395,6 +406,35 @@ fun LazyColumn( isVertical = true, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, + content = content + ) +} + +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun LazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit +) { + LazyColumn( + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), content = content ) } @@ -425,6 +465,34 @@ fun LazyColumn( ) } +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun LazyRow( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + horizontalArrangement: Arrangement.Horizontal = + if (!reverseLayout) Arrangement.Start else Arrangement.End, + verticalAlignment: Alignment.Vertical = Alignment.Top, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit +) { + LazyRow( + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), + content = content + ) +} + @Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) @Composable fun LazyRow( diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt index bc2a0b486cd77..bc592cccb960f 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt @@ -17,10 +17,10 @@ package androidx.compose.foundation.lazy import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.checkScrollableContainerConstraints import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.internal.requirePreconditionNotNull import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -43,7 +43,6 @@ import androidx.compose.ui.graphics.GraphicsContext import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.Placeable import androidx.compose.ui.platform.LocalGraphicsContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalScrollCaptureInProgress import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset @@ -69,6 +68,8 @@ internal fun LazyList( flingBehavior: FlingBehavior, /** Whether scrolling via the user gestures is allowed. */ userScrollEnabled: Boolean, + /** The overscroll effect to render and dispatch events to */ + overscrollEffect: OverscrollEffect?, /** Number of items to layout before and after the visible items */ beyondBoundsItemCount: Int = 0, /** The alignment to align items horizontally. Required when isVertical is true */ @@ -107,12 +108,23 @@ internal fun LazyList( ) val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal - val reverseDirection = - ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseLayout - ) + + val beyondBoundsModifier = + if (userScrollEnabled) { + Modifier.lazyLayoutBeyondBoundsModifier( + state = + rememberLazyListBeyondBoundsState( + state = state, + beyondBoundsItemCount = beyondBoundsItemCount + ), + beyondBoundsInfo = state.beyondBoundsInfo, + reverseLayout = reverseLayout, + orientation = orientation + ) + } else { + Modifier + } + LazyLayout( modifier = modifier @@ -125,27 +137,16 @@ internal fun LazyList( userScrollEnabled = userScrollEnabled, reverseScrolling = reverseLayout, ) - .lazyLayoutBeyondBoundsModifier( - state = - rememberLazyListBeyondBoundsState( - state = state, - beyondBoundsItemCount = beyondBoundsItemCount - ), - beyondBoundsInfo = state.beyondBoundsInfo, - reverseLayout = reverseLayout, - layoutDirection = LocalLayoutDirection.current, - orientation = orientation, - enabled = userScrollEnabled - ) + .then(beyondBoundsModifier) .then(state.itemAnimator.modifier) .scrollingContainer( state = state, orientation = orientation, enabled = userScrollEnabled, - reverseDirection = reverseDirection, + reverseScrolling = reverseLayout, flingBehavior = flingBehavior, interactionSource = state.internalInteractionSource, - overscrollEffect = ScrollableDefaults.overscrollEffect() + overscrollEffect = overscrollEffect ), prefetchState = state.prefetchState, measurePolicy = measurePolicy, @@ -198,7 +199,7 @@ private fun rememberLazyListMeasurePolicy( { containerConstraints -> state.measurementScopeInvalidator.attachToScope() // Tracks if the lookahead pass has occurred - val hasLookaheadPassOccurred = state.hasLookaheadPassOccurred || isLookingAhead + val hasLookaheadOccurred = state.hasLookaheadOccurred || isLookingAhead checkScrollableContainerConstraints( containerConstraints, if (isVertical) Orientation.Vertical else Orientation.Horizontal @@ -339,7 +340,7 @@ private fun rememberLazyListMeasurePolicy( ) val scrollToBeConsumed = - if (isLookingAhead || !hasLookaheadPassOccurred) { + if (isLookingAhead || !hasLookaheadOccurred) { state.scrollToBeConsumed } else { state.scrollDeltaBetweenPasses @@ -366,9 +367,9 @@ private fun rememberLazyListMeasurePolicy( itemAnimator = state.itemAnimator, beyondBoundsItemCount = beyondBoundsItemCount, pinnedItems = pinnedItems, - hasLookaheadPassOccurred = hasLookaheadPassOccurred, + hasLookaheadOccurred = hasLookaheadOccurred, isLookingAhead = isLookingAhead, - postLookaheadLayoutInfo = state.postLookaheadLayoutInfo, + approachLayoutInfo = state.approachLayoutInfo, coroutineScope = coroutineScope, placementScopeInvalidator = state.placementScopeInvalidator, graphicsContext = graphicsContext, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt index f69394e8b24b8..b22bce885bcc7 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt @@ -67,9 +67,9 @@ internal fun measureLazyList( itemAnimator: LazyLayoutItemAnimator, beyondBoundsItemCount: Int, pinnedItems: List, - hasLookaheadPassOccurred: Boolean, + hasLookaheadOccurred: Boolean, isLookingAhead: Boolean, - postLookaheadLayoutInfo: LazyListLayoutInfo?, + approachLayoutInfo: LazyListLayoutInfo?, coroutineScope: CoroutineScope, placementScopeInvalidator: ObservableScopeInvalidator, graphicsContext: GraphicsContext, @@ -93,7 +93,7 @@ internal fun measureLazyList( isVertical = isVertical, laneCount = 1, isLookingAhead = isLookingAhead, - hasLookaheadOccurred = hasLookaheadPassOccurred, + hasLookaheadOccurred = hasLookaheadOccurred, layoutMinOffset = 0, layoutMaxOffset = 0, coroutineScope = coroutineScope, @@ -330,7 +330,7 @@ internal fun measureLazyList( pinnedItems = pinnedItems, consumedScroll = consumedScroll, isLookingAhead = isLookingAhead, - lastPostLookaheadLayoutInfo = postLookaheadLayoutInfo + lastApproachLayoutInfo = approachLayoutInfo ) // Update maxCrossAxis with extra items @@ -373,7 +373,7 @@ internal fun measureLazyList( isVertical = isVertical, laneCount = 1, isLookingAhead = isLookingAhead, - hasLookaheadOccurred = hasLookaheadPassOccurred, + hasLookaheadOccurred = hasLookaheadOccurred, coroutineScope = coroutineScope, layoutMinOffset = currentFirstItemScrollOffset, layoutMaxOffset = currentMainAxisOffset, @@ -468,7 +468,7 @@ private fun createItemsAfterList( pinnedItems: List, consumedScroll: Float, isLookingAhead: Boolean, - lastPostLookaheadLayoutInfo: LazyListLayoutInfo? + lastApproachLayoutInfo: LazyListLayoutInfo? ): List { var list: MutableList? = null @@ -482,15 +482,14 @@ private fun createItemsAfterList( } if (isLookingAhead) { - // Check if there's any item that needs to be composed based on last postLookaheadLayoutInfo + // Check if there's any item that needs to be composed based on last approachLayoutInfo if ( - lastPostLookaheadLayoutInfo != null && - lastPostLookaheadLayoutInfo.visibleItemsInfo.isNotEmpty() + lastApproachLayoutInfo != null && lastApproachLayoutInfo.visibleItemsInfo.isNotEmpty() ) { // Find first item with index > end. Note that `visibleItemsInfo.last()` may not have // the largest index as the last few items could be added to animate item placement. val firstItem = - lastPostLookaheadLayoutInfo.visibleItemsInfo.run { + lastApproachLayoutInfo.visibleItemsInfo.run { var found: LazyListItemInfo? = null for (i in size - 1 downTo 0) { if (this[i].index > end && (i == 0 || this[i - 1].index <= end)) { @@ -500,7 +499,7 @@ private fun createItemsAfterList( } found } - val lastVisibleItem = lastPostLookaheadLayoutInfo.visibleItemsInfo.last() + val lastVisibleItem = lastApproachLayoutInfo.visibleItemsInfo.last() if (firstItem != null) { for (i in firstItem.index..min(lastVisibleItem.index, itemsCount - 1)) { // Only add to the list items that are _not_ already in the list. @@ -514,7 +513,7 @@ private fun createItemsAfterList( // Calculate the additional offset to subcompose based on what was shown in the // previous post-loookahead pass and the scroll consumed. val additionalOffset = - lastPostLookaheadLayoutInfo.viewportEndOffset - + lastApproachLayoutInfo.viewportEndOffset - lastVisibleItem.offset - lastVisibleItem.size - consumedScroll diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt index 42d4be0354c3b..c5559a87036ef 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt @@ -17,13 +17,6 @@ package androidx.compose.foundation.lazy import androidx.annotation.IntRange as AndroidXIntRange -import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.animateTo -import androidx.compose.animation.core.copy -import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.gestures.Orientation @@ -38,6 +31,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimator import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState +import androidx.compose.foundation.lazy.layout.LazyLayoutScrollDeltaBetweenPasses import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator import androidx.compose.foundation.lazy.layout.animateScrollToItem import androidx.compose.runtime.Composable @@ -57,7 +51,6 @@ import androidx.compose.ui.layout.Remeasurement import androidx.compose.ui.layout.RemeasurementModifier import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastRoundToInt import kotlin.coroutines.EmptyCoroutineContext import kotlin.math.abs @@ -144,10 +137,10 @@ constructor( firstVisibleItemScrollOffset: Int = 0 ) : this(firstVisibleItemIndex, firstVisibleItemScrollOffset, LazyListPrefetchStrategy()) - internal var hasLookaheadPassOccurred: Boolean = false + internal var hasLookaheadOccurred: Boolean = false private set - internal var postLookaheadLayoutInfo: LazyListMeasureResult? = null + internal var approachLayoutInfo: LazyListMeasureResult? = null private set /** The holder class for the current scroll position. */ @@ -416,21 +409,21 @@ constructor( var scrolledLayoutInfo = layoutInfoState.value.copyWithScrollDeltaWithoutRemeasure( delta = intDelta, - updateAnimations = !hasLookaheadPassOccurred + updateAnimations = !hasLookaheadOccurred ) - if (scrolledLayoutInfo != null && this.postLookaheadLayoutInfo != null) { + if (scrolledLayoutInfo != null && this.approachLayoutInfo != null) { // if we were able to scroll the lookahead layout info without remeasure, lets - // try to do the same for post lookahead layout info (sometimes they diverge). - val scrolledPostLookaheadLayoutInfo = - postLookaheadLayoutInfo?.copyWithScrollDeltaWithoutRemeasure( + // try to do the same for approach layout info (sometimes they diverge). + val scrolledApproachLayoutInfo = + approachLayoutInfo?.copyWithScrollDeltaWithoutRemeasure( delta = intDelta, updateAnimations = true ) - if (scrolledPostLookaheadLayoutInfo != null) { + if (scrolledApproachLayoutInfo != null) { // we can apply scroll delta for both phases without remeasure - postLookaheadLayoutInfo = scrolledPostLookaheadLayoutInfo + approachLayoutInfo = scrolledApproachLayoutInfo } else { - // we can't apply scroll delta for post lookahead, so we have to remeasure + // we can't apply scroll delta for approach, so we have to remeasure scrolledLayoutInfo = null } } @@ -438,7 +431,7 @@ constructor( if (scrolledLayoutInfo != null) { applyMeasureResult( result = scrolledLayoutInfo, - isLookingAhead = hasLookaheadPassOccurred, + isLookingAhead = hasLookaheadOccurred, visibleItemsStayedTheSame = true ) // we don't need to remeasure, so we only trigger re-placement: @@ -485,7 +478,7 @@ constructor( suspend fun animateScrollToItem(@AndroidXIntRange(from = 0) index: Int, scrollOffset: Int = 0) { scroll { LazyLayoutScrollScope(this@LazyListState, this) - .animateScrollToItem(index, scrollOffset, NumberOfItemsToTeleport, density, this) + .animateScrollToItem(index, scrollOffset, NumberOfItemsToTeleport, density) } } @@ -495,12 +488,12 @@ constructor( isLookingAhead: Boolean, visibleItemsStayedTheSame: Boolean = false ) { - if (!isLookingAhead && hasLookaheadPassOccurred) { - // If there was already a lookahead pass, record this result as postLookahead result - postLookaheadLayoutInfo = result + if (!isLookingAhead && hasLookaheadOccurred) { + // If there was already a lookahead pass, record this result as approach result + approachLayoutInfo = result } else { if (isLookingAhead) { - hasLookaheadPassOccurred = true + hasLookaheadOccurred = true } canScrollBackward = result.canScrollBackward @@ -518,7 +511,7 @@ constructor( } if (isLookingAhead) { - updateScrollDeltaForPostLookahead( + _lazyLayoutScrollDeltaBetweenPasses.updateScrollDeltaForApproach( result.scrollBackAmount, result.density, result.coroutineScope @@ -529,48 +522,9 @@ constructor( } internal val scrollDeltaBetweenPasses: Float - get() = _scrollDeltaBetweenPasses.value + get() = _lazyLayoutScrollDeltaBetweenPasses.scrollDeltaBetweenPasses - private var _scrollDeltaBetweenPasses: AnimationState = - AnimationState(Float.VectorConverter, 0f, 0f) - - // Updates the scroll delta between lookahead & post-lookahead pass - private fun updateScrollDeltaForPostLookahead( - delta: Float, - density: Density, - coroutineScope: CoroutineScope - ) { - if (delta <= with(density) { DeltaThresholdForScrollAnimation.toPx() }) { - // If the delta is within the threshold, scroll by the delta amount instead of animating - return - } - - // Scroll delta is updated during lookahead, we don't need to trigger lookahead when - // the delta changes. - Snapshot.withoutReadObservation { - val currentDelta = _scrollDeltaBetweenPasses.value - - if (_scrollDeltaBetweenPasses.isRunning) { - _scrollDeltaBetweenPasses = _scrollDeltaBetweenPasses.copy(currentDelta - delta) - coroutineScope.launch { - _scrollDeltaBetweenPasses.animateTo( - 0f, - spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f), - true - ) - } - } else { - _scrollDeltaBetweenPasses = AnimationState(Float.VectorConverter, -delta) - coroutineScope.launch { - _scrollDeltaBetweenPasses.animateTo( - 0f, - spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f), - true - ) - } - } - } - } + private val _lazyLayoutScrollDeltaBetweenPasses = LazyLayoutScrollDeltaBetweenPasses() /** * When the user provided custom keys for the items we can try to detect when there were items @@ -614,8 +568,6 @@ constructor( } } -private val DeltaThresholdForScrollAnimation = 1.dp - private val EmptyLazyListMeasureResult = LazyListMeasureResult( firstVisibleItem = null, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt index ff524f2b6b88a..27c5b21c7af8e 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt @@ -17,6 +17,7 @@ package androidx.compose.foundation.lazy.grid import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.checkScrollableContainerConstraints import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation @@ -42,7 +43,6 @@ import androidx.compose.ui.graphics.GraphicsContext import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.Placeable import androidx.compose.ui.platform.LocalGraphicsContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalScrollCaptureInProgress import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset @@ -72,6 +72,8 @@ internal fun LazyGrid( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), /** Whether scrolling via the user gestures is allowed. */ userScrollEnabled: Boolean, + /** The overscroll effect to render and dispatch events to */ + overscrollEffect: OverscrollEffect?, /** The vertical arrangement for items/lines. */ verticalArrangement: Arrangement.Vertical, /** The horizontal arrangement for items/lines. */ @@ -103,12 +105,18 @@ internal fun LazyGrid( ) val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal - val reverseDirection = - ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseLayout - ) + + val beyondBoundsModifier = + if (userScrollEnabled) { + Modifier.lazyLayoutBeyondBoundsModifier( + state = rememberLazyGridBeyondBoundsState(state = state), + beyondBoundsInfo = state.beyondBoundsInfo, + reverseLayout = reverseLayout, + orientation = orientation + ) + } else { + Modifier + } LazyLayout( modifier = @@ -122,23 +130,16 @@ internal fun LazyGrid( userScrollEnabled = userScrollEnabled, reverseScrolling = reverseLayout, ) - .lazyLayoutBeyondBoundsModifier( - state = rememberLazyGridBeyondBoundsState(state = state), - beyondBoundsInfo = state.beyondBoundsInfo, - reverseLayout = reverseLayout, - layoutDirection = LocalLayoutDirection.current, - orientation = orientation, - enabled = userScrollEnabled - ) + .then(beyondBoundsModifier) .then(state.itemAnimator.modifier) .scrollingContainer( state = state, orientation = orientation, enabled = userScrollEnabled, - reverseDirection = reverseDirection, + reverseScrolling = reverseLayout, flingBehavior = flingBehavior, interactionSource = state.internalInteractionSource, - overscrollEffect = ScrollableDefaults.overscrollEffect() + overscrollEffect = overscrollEffect ), prefetchState = state.prefetchState, measurePolicy = measurePolicy, @@ -187,6 +188,8 @@ private fun rememberLazyGridMeasurePolicy( ) { { containerConstraints -> state.measurementScopeInvalidator.attachToScope() + // Tracks if the lookahead pass has occurred + val isInLookaheadScope = state.hasLookaheadOccurred || isLookingAhead checkScrollableContainerConstraints( containerConstraints, if (isVertical) Orientation.Vertical else Orientation.Horizontal @@ -365,6 +368,13 @@ private fun rememberLazyGridMeasurePolicy( state.beyondBoundsInfo ) + val scrollToBeConsumed = + if (isLookingAhead || !isInLookaheadScope) { + state.scrollToBeConsumed + } else { + state.scrollDeltaBetweenPasses + } + // todo: wrap with snapshot when b/341782245 is resolved val measureResult = measureLazyGrid( @@ -377,7 +387,7 @@ private fun rememberLazyGridMeasurePolicy( spaceBetweenLines = spaceBetweenLines, firstVisibleLineIndex = firstVisibleLineIndex, firstVisibleLineScrollOffset = firstVisibleLineScrollOffset, - scrollToBeConsumed = state.scrollToBeConsumed, + scrollToBeConsumed = scrollToBeConsumed, constraints = contentConstraints, isVertical = isVertical, verticalArrangement = verticalArrangement, @@ -387,6 +397,9 @@ private fun rememberLazyGridMeasurePolicy( itemAnimator = state.itemAnimator, slotsPerLine = slotsPerLine, pinnedItems = pinnedItems, + isInLookaheadScope = isInLookaheadScope, + isLookingAhead = isLookingAhead, + approachLayoutInfo = state.approachLayoutInfo, coroutineScope = coroutineScope, placementScopeInvalidator = state.placementScopeInvalidator, prefetchInfoRetriever = prefetchInfoRetriever, @@ -401,7 +414,7 @@ private fun rememberLazyGridMeasurePolicy( ) } ) - state.applyMeasureResult(measureResult) + state.applyMeasureResult(measureResult, isLookingAhead = isLookingAhead) measureResult } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridDsl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridDsl.kt index 509a300ea77d4..b5835f8957988 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridDsl.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridDsl.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.lazy.grid +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.internal.requirePrecondition @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember @@ -58,6 +60,9 @@ import androidx.compose.ui.unit.dp * @param flingBehavior logic describing fling behavior * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions is * allowed. You can still scroll programmatically using the state even when it is disabled. + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * layout. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param content the [LazyGridScope] which describes the content */ @Composable @@ -72,6 +77,7 @@ fun LazyVerticalGrid( horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyGridScope.() -> Unit ) { LazyGrid( @@ -85,6 +91,37 @@ fun LazyVerticalGrid( verticalArrangement = verticalArrangement, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, + content = content + ) +} + +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun LazyVerticalGrid( + columns: GridCells, + modifier: Modifier = Modifier, + state: LazyGridState = rememberLazyGridState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyGridScope.() -> Unit +) { + LazyVerticalGrid( + columns = columns, + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalArrangement = horizontalArrangement, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), content = content ) } @@ -112,6 +149,9 @@ fun LazyVerticalGrid( * @param flingBehavior logic describing fling behavior * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions is * allowed. You can still scroll programmatically using the state even when it is disabled. + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * layout. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param content the [LazyGridScope] which describes the content */ @Composable @@ -126,6 +166,7 @@ fun LazyHorizontalGrid( verticalArrangement: Arrangement.Vertical = Arrangement.Top, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyGridScope.() -> Unit ) { LazyGrid( @@ -139,6 +180,37 @@ fun LazyHorizontalGrid( verticalArrangement = verticalArrangement, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, + content = content + ) +} + +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun LazyHorizontalGrid( + rows: GridCells, + modifier: Modifier = Modifier, + state: LazyGridState = rememberLazyGridState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + horizontalArrangement: Arrangement.Horizontal = + if (!reverseLayout) Arrangement.Start else Arrangement.End, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyGridScope.() -> Unit +) { + LazyHorizontalGrid( + rows = rows, + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + horizontalArrangement = horizontalArrangement, + verticalArrangement = verticalArrangement, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), content = content ) } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt index 97d75c39f023c..778bf8fd7eeff 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachReversed import androidx.compose.ui.util.fastRoundToInt @@ -68,6 +69,9 @@ internal fun measureLazyGrid( itemAnimator: LazyLayoutItemAnimator, slotsPerLine: Int, pinnedItems: List, + isInLookaheadScope: Boolean, + isLookingAhead: Boolean, + approachLayoutInfo: LazyGridLayoutInfo?, coroutineScope: CoroutineScope, placementScopeInvalidator: ObservableScopeInvalidator, graphicsContext: GraphicsContext, @@ -90,17 +94,19 @@ internal fun measureLazyGrid( itemProvider = measuredItemProvider, isVertical = isVertical, laneCount = slotsPerLine, - isLookingAhead = false, - hasLookaheadOccurred = false, + isLookingAhead = isLookingAhead, + hasLookaheadOccurred = isInLookaheadScope, layoutMinOffset = 0, layoutMaxOffset = 0, coroutineScope = coroutineScope, graphicsContext = graphicsContext ) - val disappearingItemsSize = itemAnimator.minSizeToFitDisappearingItems - if (disappearingItemsSize != IntSize.Zero) { - layoutWidth = constraints.constrainWidth(disappearingItemsSize.width) - layoutHeight = constraints.constrainHeight(disappearingItemsSize.height) + if (!isLookingAhead) { + val disappearingItemsSize = itemAnimator.minSizeToFitDisappearingItems + if (disappearingItemsSize != IntSize.Zero) { + layoutWidth = constraints.constrainWidth(disappearingItemsSize.width) + layoutHeight = constraints.constrainHeight(disappearingItemsSize.height) + } } return LazyGridMeasureResult( firstVisibleLine = null, @@ -108,6 +114,7 @@ internal fun measureLazyGrid( canScrollForward = false, consumedScroll = 0f, measureResult = layout(layoutWidth, layoutHeight) {}, + scrollBackAmount = 0f, visibleItemsInfo = emptyList(), viewportStartOffset = -beforeContentPadding, viewportEndOffset = mainAxisAvailableSize + afterContentPadding, @@ -225,6 +232,7 @@ internal fun measureLazyGrid( index++ } + val preScrollBackScrollDelta = scrollDelta // we didn't fill the whole viewport with lines starting from firstVisibleLineIndex. // lets try to scroll back if we have enough lines before firstVisibleLineIndex. if (currentMainAxisOffset < maxOffset) { @@ -262,6 +270,15 @@ internal fun measureLazyGrid( scrollToBeConsumed } + val unconsumedScroll = scrollToBeConsumed - consumedScroll + // When scrolling to the bottom via gesture, there could be scrollback due to + // not being able to consume the whole scroll. In that case, the amount of + // scrollBack is the inverse of unconsumed scroll. + val scrollBackAmount: Float = + if (isLookingAhead && scrollDelta > preScrollBackScrollDelta && unconsumedScroll <= 0) { + scrollDelta - preScrollBackScrollDelta + unconsumedScroll + } else 0f + // the initial offset for lines from visibleLines list requirePrecondition(currentFirstLineScrollOffset >= 0) { "negative initial offset" } val visibleLinesScrollOffset = -currentFirstLineScrollOffset @@ -277,12 +294,28 @@ internal fun measureLazyGrid( filter = { it in 0 until firstItemIndex } ) + val linesRetainedForLookahead = + linesRetainedForLookahead( + lastVisibleItemIndex = lastItemIndex, + itemsCount = itemsCount, + measuredLineProvider, + isLookingAhead = isLookingAhead, + visibleLines = visibleLines, + lastApproachLayoutInfo = approachLayoutInfo + ) + val extraItemsAfter = calculateExtraItems( pinnedItems = pinnedItems, measuredItemProvider = measuredItemProvider, measuredLineProvider = measuredLineProvider, - filter = { it in (lastItemIndex + 1) until itemsCount } + filter = { + it in (lastItemIndex + 1) until itemsCount && + (!isLookingAhead || + !linesRetainedForLookahead.fastAny { line -> + line.items.any { item -> item.index == it } + }) + } ) // even if we compose lines to fill before content padding we should ignore lines fully @@ -318,7 +351,9 @@ internal fun measureLazyGrid( val positionedItems = calculateItemsOffsets( - lines = visibleLines, + lines = + if (linesRetainedForLookahead.isEmpty()) visibleLines + else visibleLines + linesRetainedForLookahead, itemsBefore = extraItemsBefore, itemsAfter = extraItemsAfter, layoutWidth = layoutWidth, @@ -342,24 +377,26 @@ internal fun measureLazyGrid( itemProvider = measuredItemProvider, isVertical = isVertical, laneCount = slotsPerLine, - isLookingAhead = false, - hasLookaheadOccurred = false, + isLookingAhead = isLookingAhead, + hasLookaheadOccurred = isInLookaheadScope, layoutMinOffset = currentFirstLineScrollOffset, layoutMaxOffset = currentMainAxisOffset, coroutineScope = coroutineScope, graphicsContext = graphicsContext ) - val disappearingItemsSize = itemAnimator.minSizeToFitDisappearingItems - if (disappearingItemsSize != IntSize.Zero) { - val oldMainAxisSize = if (isVertical) layoutHeight else layoutWidth - layoutWidth = - constraints.constrainWidth(maxOf(layoutWidth, disappearingItemsSize.width)) - layoutHeight = - constraints.constrainHeight(maxOf(layoutHeight, disappearingItemsSize.height)) - val newMainAxisSize = if (isVertical) layoutHeight else layoutWidth - if (newMainAxisSize != oldMainAxisSize) { - positionedItems.fastForEach { it.updateMainAxisLayoutSize(newMainAxisSize) } + if (!isLookingAhead) { + val disappearingItemsSize = itemAnimator.minSizeToFitDisappearingItems + if (disappearingItemsSize != IntSize.Zero) { + val oldMainAxisSize = if (isVertical) layoutHeight else layoutWidth + layoutWidth = + constraints.constrainWidth(maxOf(layoutWidth, disappearingItemsSize.width)) + layoutHeight = + constraints.constrainHeight(maxOf(layoutHeight, disappearingItemsSize.height)) + val newMainAxisSize = if (isVertical) layoutHeight else layoutWidth + if (newMainAxisSize != oldMainAxisSize) { + positionedItems.fastForEach { it.updateMainAxisLayoutSize(newMainAxisSize) } + } } } @@ -396,12 +433,13 @@ internal fun measureLazyGrid( // speaking, this signals a preference to directly apply changes rather than // animating, to avoid a chasing effect to scrolling. withMotionFrameOfReferencePlacement { - positionedItems.fastForEach { it.place(this) } - stickingItems.fastForEach { it.place(this) } + positionedItems.fastForEach { it.place(this, isLookingAhead) } + stickingItems.fastForEach { it.place(this, isLookingAhead) } } // we attach it during the placement so LazyGridState can trigger re-placement placementScopeInvalidator.attachToScope() }, + scrollBackAmount = scrollBackAmount, viewportStartOffset = -beforeContentPadding, viewportEndOffset = mainAxisAvailableSize + afterContentPadding, visibleItemsInfo = @@ -449,6 +487,59 @@ private inline fun calculateExtraItems( return items ?: emptyList() } +/** + * Retain the items from last approach pass until they are no longer needed in the approach. This + * avoids disposing items in lookahead too early, which would lead to the items being composed in a + * different node later in the approach and lose all its internal states. + */ +private fun linesRetainedForLookahead( + lastVisibleItemIndex: Int, + itemsCount: Int, + measuredLineProvider: LazyGridMeasuredLineProvider, + isLookingAhead: Boolean, + visibleLines: List, + lastApproachLayoutInfo: LazyGridLayoutInfo? +): List { + var list: MutableList? = null + + if (isLookingAhead) { + // Check if there's any item that needs to be composed based on last approachLayoutInfo + if ( + lastApproachLayoutInfo != null && lastApproachLayoutInfo.visibleItemsInfo.isNotEmpty() + ) { + // Find first item with index > end. Note that `visibleItemsInfo.last()` may not have + // the largest index as the last few items could be added to animate item placement. + val firstItem = + lastApproachLayoutInfo.visibleItemsInfo.run { + var found: LazyGridItemInfo? = null + for (i in size - 1 downTo 0) { + if ( + this[i].index > lastVisibleItemIndex && + (i == 0 || this[i - 1].index <= lastVisibleItemIndex) + ) { + found = this[i] + break + } + } + found + } + val lastVisibleItem = lastApproachLayoutInfo.visibleItemsInfo.last() + var lineIndex = visibleLines.lastOrNull()?.let { it.index + 1 } ?: 0 + if (firstItem != null) { + for (i in firstItem.index..min(lastVisibleItem.index, itemsCount - 1)) { + if (list?.fastAny { it.items.any { it.index == i } } != true) { + if (list == null) list = mutableListOf() + val measuredLine = measuredLineProvider.getAndMeasure(lineIndex = lineIndex) + lineIndex++ + list.add(measuredLine) + } + } + } + } + } + return list ?: emptyList() +} + /** Calculates [LazyGridMeasuredLine]s offsets. */ private fun calculateItemsOffsets( lines: List, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasureResult.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasureResult.kt index f3b8ce0686ef2..d964161ea25d1 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasureResult.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasureResult.kt @@ -38,6 +38,8 @@ internal class LazyGridMeasureResult( val consumedScroll: Float, /** MeasureResult defining the layout. */ private val measureResult: MeasureResult, + /** The amount of scroll-back that happened due to reaching the end of the list. */ + val scrollBackAmount: Float, /** True when extra remeasure is required. */ val remeasureNeeded: Boolean, /** Scope for animations. */ @@ -90,7 +92,10 @@ internal class LazyGridMeasureResult( * If If new layout info is returned, only the placement phase is needed to apply new offsets. * If null is returned, it means we have to rerun the full measure phase to apply the [delta]. */ - fun copyWithScrollDeltaWithoutRemeasure(delta: Int): LazyGridMeasureResult? { + fun copyWithScrollDeltaWithoutRemeasure( + delta: Int, + updateAnimations: Boolean + ): LazyGridMeasureResult? { if ( remeasureNeeded || visibleItemsInfo.isEmpty() || @@ -125,7 +130,7 @@ internal class LazyGridMeasureResult( minOf(deltaToFirstItemChange, deltaToLastItemChange) > delta } return if (canApply) { - visibleItemsInfo.fastForEach { it.applyScrollDelta(delta) } + visibleItemsInfo.fastForEach { it.applyScrollDelta(delta, updateAnimations) } LazyGridMeasureResult( firstVisibleLine = firstVisibleLine, firstVisibleLineScrollOffset = firstVisibleLineScrollOffset - delta, @@ -133,6 +138,7 @@ internal class LazyGridMeasureResult( canScrollForward || delta > 0, // we scrolled backward, so now we can scroll forward consumedScroll = delta.toFloat(), + scrollBackAmount = scrollBackAmount, measureResult = measureResult, remeasureNeeded = remeasureNeeded, coroutineScope = coroutineScope, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt index 72c3a2bbf566e..8091ede1508bd 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt @@ -17,6 +17,7 @@ package androidx.compose.foundation.lazy.grid import androidx.compose.foundation.internal.requirePrecondition +import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimation.Companion.NotInitialized import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimator import androidx.compose.foundation.lazy.layout.LazyLayoutMeasuredItem import androidx.compose.ui.graphics.layer.GraphicsLayer @@ -165,43 +166,55 @@ internal class LazyGridMeasuredItem( maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding } - fun applyScrollDelta(delta: Int) { + fun applyScrollDelta(delta: Int, updateAnimations: Boolean) { if (nonScrollableItem) { return } offset = offset.copy { it + delta } - repeat(placeablesCount) { index -> - val animation = animator.getAnimation(key, index) - if (animation != null) { - animation.rawOffset = animation.rawOffset.copy { mainAxis -> mainAxis + delta } + if (updateAnimations) { + repeat(placeablesCount) { index -> + val animation = animator.getAnimation(key, index) + if (animation != null) { + animation.rawOffset = animation.rawOffset.copy { mainAxis -> mainAxis + delta } + } } } } - fun place( - scope: Placeable.PlacementScope, - ) = + fun place(scope: Placeable.PlacementScope, isLookingAhead: Boolean) = with(scope) { requirePrecondition(mainAxisLayoutSize != Unset) { "position() should be called first" } repeat(placeablesCount) { index -> val placeable = placeables[index] val minOffset = minMainAxisOffset - placeable.mainAxisSize val maxOffset = maxMainAxisOffset - var offset = offset val animation = animator.getAnimation(key, index) val layer: GraphicsLayer? if (animation != null) { - val animatedOffset = offset + animation.placementDelta - // cancel the animation if current and target offsets are both out of the - // bounds. - if ( - (offset.mainAxis <= minOffset && animatedOffset.mainAxis <= minOffset) || - (offset.mainAxis >= maxOffset && animatedOffset.mainAxis >= maxOffset) - ) { - animation.cancelPlacementAnimation() + if (isLookingAhead) { + // Skip animation in lookahead pass + animation.lookaheadOffset = offset + } else { + val targetOffset = + if (animation.lookaheadOffset != NotInitialized) { + animation.lookaheadOffset + } else { + offset + } + val animatedOffset = targetOffset + animation.placementDelta + // cancel the animation if current and target offsets are both out of the + // bounds. + if ( + (offset.mainAxis <= minOffset && + animatedOffset.mainAxis <= minOffset) || + (offset.mainAxis >= maxOffset && + animatedOffset.mainAxis >= maxOffset) + ) { + animation.cancelPlacementAnimation() + } + offset = animatedOffset } - offset = animatedOffset layer = animation.layer } else { layer = null @@ -213,7 +226,9 @@ internal class LazyGridMeasuredItem( } } offset += visualOffset - animation?.finalOffset = offset + if (!isLookingAhead) { + animation?.finalOffset = offset + } if (isVertical) { if (layer != null) { placeable.placeWithLayer(offset, layer) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt index 23a9c30bb242d..9a578ca51bdcd 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt @@ -119,7 +119,9 @@ internal class LazyGridSpanLayoutProvider(private val gridContent: LazyGridInter cachedBucket.clear() } - checkPrecondition(currentLine <= lineIndex) { "currentLine > lineIndex" } + checkPrecondition(currentLine <= lineIndex) { + "currentLine ($currentLine) > lineIndex ($lineIndex)" + } while (currentLine < lineIndex && currentItemIndex < totalSize) { if (cacheThisBucket) { diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt index db020489df6c5..73e957a2bf161 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimator import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState +import androidx.compose.foundation.lazy.layout.LazyLayoutScrollDeltaBetweenPasses import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator import androidx.compose.foundation.lazy.layout.animateScrollToItem import androidx.compose.runtime.Composable @@ -136,6 +137,12 @@ constructor( firstVisibleItemScrollOffset: Int = 0 ) : this(firstVisibleItemIndex, firstVisibleItemScrollOffset, LazyGridPrefetchStrategy()) + internal var hasLookaheadOccurred: Boolean = false + private set + + internal var approachLayoutInfo: LazyGridMeasureResult? = null + private set + /** The holder class for the current scroll position. */ private val scrollPosition = LazyGridScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset) @@ -397,10 +404,33 @@ constructor( if (abs(scrollToBeConsumed) > 0.5f) { val preScrollToBeConsumed = scrollToBeConsumed val intDelta = scrollToBeConsumed.roundToInt() - val scrolledLayoutInfo = - layoutInfoState.value.copyWithScrollDeltaWithoutRemeasure(delta = intDelta) + var scrolledLayoutInfo = + layoutInfoState.value.copyWithScrollDeltaWithoutRemeasure( + delta = intDelta, + updateAnimations = !hasLookaheadOccurred + ) + if (scrolledLayoutInfo != null && this.approachLayoutInfo != null) { + // if we were able to scroll the lookahead layout info without remeasure, lets + // try to do the same for post lookahead layout info (sometimes they diverge). + val scrolledApproachLayoutInfo = + approachLayoutInfo?.copyWithScrollDeltaWithoutRemeasure( + delta = intDelta, + updateAnimations = true + ) + if (scrolledApproachLayoutInfo != null) { + // we can apply scroll delta for both phases without remeasure + approachLayoutInfo = scrolledApproachLayoutInfo + } else { + // we can't apply scroll delta for post lookahead, so we have to remeasure + scrolledLayoutInfo = null + } + } if (scrolledLayoutInfo != null) { - applyMeasureResult(result = scrolledLayoutInfo, visibleItemsStayedTheSame = true) + applyMeasureResult( + result = scrolledLayoutInfo, + isLookingAhead = hasLookaheadOccurred, + visibleItemsStayedTheSame = true + ) // we don't need to remeasure, so we only trigger re-placement: placementScopeInvalidator.invalidateScope() @@ -448,33 +478,54 @@ constructor( suspend fun animateScrollToItem(@AndroidXIntRange(from = 0) index: Int, scrollOffset: Int = 0) { scroll { LazyLayoutScrollScope(this@LazyGridState, this) - .animateScrollToItem(index, scrollOffset, numOfItemsToTeleport, density, this) + .animateScrollToItem(index, scrollOffset, numOfItemsToTeleport, density) } } /** Updates the state with the new calculated scroll position and consumed scroll. */ internal fun applyMeasureResult( result: LazyGridMeasureResult, + isLookingAhead: Boolean, visibleItemsStayedTheSame: Boolean = false ) { - scrollToBeConsumed -= result.consumedScroll - layoutInfoState.value = result + if (!isLookingAhead && hasLookaheadOccurred) { + // If there was already a lookahead pass, record this result as Approach result + approachLayoutInfo = result + } else { + if (isLookingAhead) { + hasLookaheadOccurred = true + } + scrollToBeConsumed -= result.consumedScroll + layoutInfoState.value = result - canScrollBackward = result.canScrollBackward - canScrollForward = result.canScrollForward + canScrollBackward = result.canScrollBackward + canScrollForward = result.canScrollForward - if (visibleItemsStayedTheSame) { - scrollPosition.updateScrollOffset(result.firstVisibleLineScrollOffset) - } else { - scrollPosition.updateFromMeasureResult(result) - if (prefetchingEnabled) { - with(prefetchStrategy) { prefetchScope.onVisibleItemsUpdated(result) } + if (visibleItemsStayedTheSame) { + scrollPosition.updateScrollOffset(result.firstVisibleLineScrollOffset) + } else { + scrollPosition.updateFromMeasureResult(result) + if (prefetchingEnabled) { + with(prefetchStrategy) { prefetchScope.onVisibleItemsUpdated(result) } + } } - } - numMeasurePasses++ + if (isLookingAhead) { + _lazyLayoutScrollDeltaBetweenPasses.updateScrollDeltaForApproach( + result.scrollBackAmount, + result.density, + result.coroutineScope + ) + } + numMeasurePasses++ + } } + private val _lazyLayoutScrollDeltaBetweenPasses = LazyLayoutScrollDeltaBetweenPasses() + + internal val scrollDeltaBetweenPasses + get() = _lazyLayoutScrollDeltaBetweenPasses.scrollDeltaBetweenPasses + /** * When the user provided custom keys for the items we can try to detect when there were items * added or removed before our current first visible item and keep this item as the first @@ -533,6 +584,7 @@ private val EmptyLazyGridLayoutInfo = override fun placeChildren() {} }, + scrollBackAmount = 0f, visibleItemsInfo = emptyList(), viewportStartOffset = 0, viewportEndOffset = 0, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutBeyondBoundsModifierLocal.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutBeyondBoundsModifierLocal.kt index ee560bc740662..93084ad6acc64 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutBeyondBoundsModifierLocal.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutBeyondBoundsModifierLocal.kt @@ -37,9 +37,9 @@ import androidx.compose.ui.modifier.modifierLocalMapOf import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.remeasureSync +import androidx.compose.ui.node.requireLayoutDirection import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection.Ltr import androidx.compose.ui.unit.LayoutDirection.Rtl @@ -51,28 +51,15 @@ internal fun Modifier.lazyLayoutBeyondBoundsModifier( state: LazyLayoutBeyondBoundsState, beyondBoundsInfo: LazyLayoutBeyondBoundsInfo, reverseLayout: Boolean, - layoutDirection: LayoutDirection, - orientation: Orientation, - enabled: Boolean + orientation: Orientation ): Modifier = - if (!enabled) { - this - } else { - this then - LazyLayoutBeyondBoundsModifierElement( - state, - beyondBoundsInfo, - reverseLayout, - layoutDirection, - orientation - ) - } + this then + LazyLayoutBeyondBoundsModifierElement(state, beyondBoundsInfo, reverseLayout, orientation) private class LazyLayoutBeyondBoundsModifierElement( val state: LazyLayoutBeyondBoundsState, val beyondBoundsInfo: LazyLayoutBeyondBoundsInfo, val reverseLayout: Boolean, - val layoutDirection: LayoutDirection, val orientation: Orientation ) : ModifierNodeElement() { override fun create(): LazyLayoutBeyondBoundsModifierNode { @@ -80,20 +67,18 @@ private class LazyLayoutBeyondBoundsModifierElement( state, beyondBoundsInfo, reverseLayout, - layoutDirection, orientation ) } override fun update(node: LazyLayoutBeyondBoundsModifierNode) { - node.update(state, beyondBoundsInfo, reverseLayout, layoutDirection, orientation) + node.update(state, beyondBoundsInfo, reverseLayout, orientation) } override fun hashCode(): Int { var result = state.hashCode() result = 31 * result + beyondBoundsInfo.hashCode() result = 31 * result + reverseLayout.hashCode() - result = 31 * result + layoutDirection.hashCode() result = 31 * result + orientation.hashCode() return result } @@ -106,7 +91,6 @@ private class LazyLayoutBeyondBoundsModifierElement( if (state != other.state) return false if (beyondBoundsInfo != other.beyondBoundsInfo) return false if (reverseLayout != other.reverseLayout) return false - if (layoutDirection != other.layoutDirection) return false if (orientation != other.orientation) return false return true @@ -121,7 +105,6 @@ internal class LazyLayoutBeyondBoundsModifierNode( private var state: LazyLayoutBeyondBoundsState, private var beyondBoundsInfo: LazyLayoutBeyondBoundsInfo, private var reverseLayout: Boolean, - private var layoutDirection: LayoutDirection, private var orientation: Orientation ) : Modifier.Node(), ModifierLocalModifierNode, BeyondBoundsLayout, LayoutModifierNode { @@ -194,12 +177,12 @@ internal class LazyLayoutBeyondBoundsModifierNode( Above -> reverseLayout Below -> !reverseLayout Left -> - when (layoutDirection) { + when (requireLayoutDirection()) { Ltr -> reverseLayout Rtl -> !reverseLayout } Right -> - when (layoutDirection) { + when (requireLayoutDirection()) { Ltr -> !reverseLayout Rtl -> reverseLayout } @@ -241,13 +224,11 @@ internal class LazyLayoutBeyondBoundsModifierNode( state: LazyLayoutBeyondBoundsState, beyondBoundsInfo: LazyLayoutBeyondBoundsInfo, reverseLayout: Boolean, - layoutDirection: LayoutDirection, orientation: Orientation ) { this.state = state this.beyondBoundsInfo = beyondBoundsInfo this.reverseLayout = reverseLayout - this.layoutDirection = layoutDirection this.orientation = orientation } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt index 1d4c4678e5a92..0542b7f240c00 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt @@ -317,10 +317,14 @@ internal class LazyLayoutItemAnimator { val itemInfo = keyToItemInfoMap[item.key]!! val accumulatedOffset = accumulatedOffsetPerLane.updateAndReturnOffsetFor(item) val mainAxisOffset = - if (isLookingAhead) positionedItems.last().mainAxisOffset - else { - itemInfo.layoutMaxOffset - item.mainAxisSizeWithSpacings - } + accumulatedOffset + if (isLookingAhead) { + // Position the moving away items starting from the end of the last + // visible item. + val lastVisibleItem = positionedItems.last() + lastVisibleItem.mainAxisOffset + lastVisibleItem.mainAxisSizeWithSpacings + } else { + itemInfo.layoutMaxOffset + } - item.mainAxisSizeWithSpacings + accumulatedOffset item.position( mainAxisOffset = mainAxisOffset, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollDeltaBetweenPasses.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollDeltaBetweenPasses.kt new file mode 100644 index 0000000000000..2785a0ae72b0c --- /dev/null +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollDeltaBetweenPasses.kt @@ -0,0 +1,88 @@ +/* + * 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. + */ + +package androidx.compose.foundation.lazy.layout + +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.copy +import androidx.compose.animation.core.spring +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * This class manages the scroll delta between lookahead pass and approach pass. Lookahead pass is + * the source of truth for scrolling lazy layouts. However, at times during an animation, the items + * in approach may not be as large as they are in lookahead yet (i.e. these items have not reached + * their target size). As such, the same scrolling that lookahead accepts may cause back scroll in + * approach due to the smaller item size at the end of the list. In this situation, we will be + * taking the amount of back scroll from the approach and gradually animate it down to 0 to avoid + * any sudden jump in position via [updateScrollDeltaForApproach]. + */ +internal class LazyLayoutScrollDeltaBetweenPasses { + + internal val scrollDeltaBetweenPasses: Float + get() = _scrollDeltaBetweenPasses.value + + private var _scrollDeltaBetweenPasses: AnimationState = + AnimationState(Float.VectorConverter, 0f, 0f) + + // Updates the scroll delta between lookahead & post-lookahead pass + internal fun updateScrollDeltaForApproach( + delta: Float, + density: Density, + coroutineScope: CoroutineScope + ) { + if (delta <= with(density) { DeltaThresholdForScrollAnimation.toPx() }) { + // If the delta is within the threshold, scroll by the delta amount instead of animating + return + } + + // Scroll delta is updated during lookahead, we don't need to trigger lookahead when + // the delta changes. + Snapshot.withoutReadObservation { + val currentDelta = _scrollDeltaBetweenPasses.value + + if (_scrollDeltaBetweenPasses.isRunning) { + _scrollDeltaBetweenPasses = _scrollDeltaBetweenPasses.copy(currentDelta - delta) + coroutineScope.launch { + _scrollDeltaBetweenPasses.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f), + true + ) + } + } else { + _scrollDeltaBetweenPasses = AnimationState(Float.VectorConverter, -delta) + coroutineScope.launch { + _scrollDeltaBetweenPasses.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f), + true + ) + } + } + } + } +} + +private val DeltaThresholdForScrollAnimation = 1.dp diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollScope.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollScope.kt index 8d8f1e21d5e05..4eb280e54819e 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollScope.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutScrollScope.kt @@ -98,199 +98,199 @@ internal fun LazyLayoutScrollScope.isItemVisible(index: Int): Boolean { return index in firstVisibleItemIndex..lastVisibleItemIndex } +/** + * Default animateScrollToItem logic to be used by any [LazyLayoutScrollScope]. + * + * @param index Target index to animate to. + * @param scrollOffset Target offset to animate to. + * @param numOfItemsForTeleport In case teleporting is needed, the number of items to jump + * ahead/back to avoid composing intermediate items. + * @param density A [Density] instance. + */ internal suspend fun LazyLayoutScrollScope.animateScrollToItem( index: Int, scrollOffset: Int, numOfItemsForTeleport: Int, - density: Density, - scrollScope: ScrollScope + density: Density ) { requirePrecondition(index >= 0f) { "Index should be non-negative" } - with(scrollScope) { - try { - val targetDistancePx = with(density) { TargetDistance.toPx() } - val boundDistancePx = with(density) { BoundDistance.toPx() } - val minDistancePx = with(density) { MinimumDistance.toPx() } - var loop = true - var anim = AnimationState(0f) + try { + val targetDistancePx = with(density) { TargetDistance.toPx() } + val boundDistancePx = with(density) { BoundDistance.toPx() } + val minDistancePx = with(density) { MinimumDistance.toPx() } + var loop = true + var anim = AnimationState(0f) - if (isItemVisible(index)) { - val targetItemInitialOffset = calculateDistanceTo(index) - // It's already visible, just animate directly - throw ItemFoundInScroll(targetItemInitialOffset, anim) - } - val forward = index > firstVisibleItemIndex + if (isItemVisible(index)) { + val targetItemInitialOffset = calculateDistanceTo(index) + // It's already visible, just animate directly + throw ItemFoundInScroll(targetItemInitialOffset, anim) + } + val forward = index > firstVisibleItemIndex - fun isOvershot(): Boolean { - // Did we scroll past the item? - @Suppress("RedundantIf") // It's way easier to understand the logic this way - return if (forward) { - if (firstVisibleItemIndex > index) { - true - } else if ( - firstVisibleItemIndex == index && - firstVisibleItemScrollOffset > scrollOffset - ) { - true - } else { - false - } - } else { // backward - if (firstVisibleItemIndex < index) { - true - } else if ( - firstVisibleItemIndex == index && - firstVisibleItemScrollOffset < scrollOffset - ) { - true - } else { - false - } + fun isOvershot(): Boolean { + // Did we scroll past the item? + @Suppress("RedundantIf") // It's way easier to understand the logic this way + return if (forward) { + if (firstVisibleItemIndex > index) { + true + } else if ( + firstVisibleItemIndex == index && firstVisibleItemScrollOffset > scrollOffset + ) { + true + } else { + false + } + } else { // backward + if (firstVisibleItemIndex < index) { + true + } else if ( + firstVisibleItemIndex == index && firstVisibleItemScrollOffset < scrollOffset + ) { + true + } else { + false } } + } - var loops = 1 - while (loop && itemCount > 0) { - val expectedDistance = calculateDistanceTo(index) + scrollOffset - val target = - if (abs(expectedDistance) < targetDistancePx) { - val absTargetPx = maxOf(abs(expectedDistance.toFloat()), minDistancePx) - if (forward) absTargetPx else -absTargetPx - } else { - if (forward) targetDistancePx else -targetDistancePx - } - - debugLog { - "Scrolling to index=$index offset=$scrollOffset from " + - "index=$firstVisibleItemIndex offset=$firstVisibleItemScrollOffset with " + - "calculated target=$target" + var loops = 1 + while (loop && itemCount > 0) { + val expectedDistance = calculateDistanceTo(index) + scrollOffset + val target = + if (abs(expectedDistance) < targetDistancePx) { + val absTargetPx = maxOf(abs(expectedDistance.toFloat()), minDistancePx) + if (forward) absTargetPx else -absTargetPx + } else { + if (forward) targetDistancePx else -targetDistancePx } - anim = anim.copy(value = 0f) - var prevValue = 0f - anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) { - // If we haven't found the item yet, check if it's visible. - debugLog { "firstVisibleItemIndex=$firstVisibleItemIndex" } - if (!isItemVisible(index)) { - // Springs can overshoot their target, clamp to the desired range - val coercedValue = - if (target > 0) { - value.coerceAtMost(target) - } else { - value.coerceAtLeast(target) - } - val delta = coercedValue - prevValue - debugLog { - "Scrolling by $delta (target: $target, coercedValue: $coercedValue)" + debugLog { + "Scrolling to index=$index offset=$scrollOffset from " + + "index=$firstVisibleItemIndex offset=$firstVisibleItemScrollOffset with " + + "calculated target=$target" + } + + anim = anim.copy(value = 0f) + var prevValue = 0f + anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) { + // If we haven't found the item yet, check if it's visible. + debugLog { "firstVisibleItemIndex=$firstVisibleItemIndex" } + if (!isItemVisible(index)) { + // Springs can overshoot their target, clamp to the desired range + val coercedValue = + if (target > 0) { + value.coerceAtMost(target) + } else { + value.coerceAtLeast(target) } + val delta = coercedValue - prevValue + debugLog { + "Scrolling by $delta (target: $target, coercedValue: $coercedValue)" + } - val consumed = scrollBy(delta) - if (isItemVisible(index)) { - debugLog { "Found the item after performing scrollBy()" } - } else if (!isOvershot()) { - if (delta != consumed) { - debugLog { "Hit end without finding the item" } + val consumed = scrollBy(delta) + if (isItemVisible(index)) { + debugLog { "Found the item after performing scrollBy()" } + } else if (!isOvershot()) { + if (delta != consumed) { + debugLog { "Hit end without finding the item" } + cancelAnimation() + loop = false + return@animateTo + } + prevValue += delta + if (forward) { + if (value > boundDistancePx) { + debugLog { "Struck bound going forward" } cancelAnimation() - loop = false - return@animateTo } - prevValue += delta - if (forward) { - if (value > boundDistancePx) { - debugLog { "Struck bound going forward" } - cancelAnimation() - } - } else { - if (value < -boundDistancePx) { - debugLog { "Struck bound going backward" } - cancelAnimation() - } + } else { + if (value < -boundDistancePx) { + debugLog { "Struck bound going backward" } + cancelAnimation() } + } - if (forward) { - if ( - loops >= 2 && - index - lastVisibleItemIndex > numOfItemsForTeleport - ) { - // Teleport - debugLog { "Teleport forward" } - snapToItem(index = index - numOfItemsForTeleport, offset = 0) - } - } else { - if ( - loops >= 2 && - firstVisibleItemIndex - index > numOfItemsForTeleport - ) { - // Teleport - debugLog { "Teleport backward" } - snapToItem(index = index + numOfItemsForTeleport, offset = 0) - } + if (forward) { + if ( + loops >= 2 && index - lastVisibleItemIndex > numOfItemsForTeleport + ) { + // Teleport + debugLog { "Teleport forward" } + snapToItem(index = index - numOfItemsForTeleport, offset = 0) + } + } else { + if ( + loops >= 2 && firstVisibleItemIndex - index > numOfItemsForTeleport + ) { + // Teleport + debugLog { "Teleport backward" } + snapToItem(index = index + numOfItemsForTeleport, offset = 0) } } } + } - // We don't throw ItemFoundInScroll when we snap, because once we've snapped to - // the final position, there's no need to animate to it. - if (isOvershot()) { - debugLog { - "Overshot, " + - "item $firstVisibleItemIndex at $firstVisibleItemScrollOffset," + - " target is $scrollOffset" - } - snapToItem(index = index, offset = scrollOffset) - loop = false - cancelAnimation() - return@animateTo - } else if (isItemVisible(index)) { - val targetItemOffset = calculateDistanceTo(index) - debugLog { "Found item" } - throw ItemFoundInScroll(targetItemOffset, anim) + // We don't throw ItemFoundInScroll when we snap, because once we've snapped to + // the final position, there's no need to animate to it. + if (isOvershot()) { + debugLog { + "Overshot, " + + "item $firstVisibleItemIndex at $firstVisibleItemScrollOffset," + + " target is $scrollOffset" } + snapToItem(index = index, offset = scrollOffset) + loop = false + cancelAnimation() + return@animateTo + } else if (isItemVisible(index)) { + val targetItemOffset = calculateDistanceTo(index) + debugLog { "Found item" } + throw ItemFoundInScroll(targetItemOffset, anim) } - - loops++ } - } catch (itemFound: ItemFoundInScroll) { - // We found it, animate to it - // Bring to the requested position - will be automatically stopped if not possible - val anim = itemFound.previousAnimation.copy(value = 0f) - val target = (itemFound.itemOffset + scrollOffset).toFloat() - var prevValue = 0f - debugLog { "Seeking by $target at velocity ${itemFound.previousAnimation.velocity}" } - anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) { - // Springs can overshoot their target, clamp to the desired range - val coercedValue = - when { - target > 0 -> { - value.coerceAtMost(target) - } - target < 0 -> { - value.coerceAtLeast(target) - } - else -> { - debugLog { - "WARNING: somehow ended up seeking 0px, this shouldn't happen" - } - 0f - } + + loops++ + } + } catch (itemFound: ItemFoundInScroll) { + // We found it, animate to it + // Bring to the requested position - will be automatically stopped if not possible + val anim = itemFound.previousAnimation.copy(value = 0f) + val target = (itemFound.itemOffset + scrollOffset).toFloat() + var prevValue = 0f + debugLog { "Seeking by $target at velocity ${itemFound.previousAnimation.velocity}" } + anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) { + // Springs can overshoot their target, clamp to the desired range + val coercedValue = + when { + target > 0 -> { + value.coerceAtMost(target) + } + target < 0 -> { + value.coerceAtLeast(target) + } + else -> { + debugLog { "WARNING: somehow ended up seeking 0px, this shouldn't happen" } + 0f } - val delta = coercedValue - prevValue - debugLog { "Seeking by $delta (coercedValue = $coercedValue)" } - val consumed = scrollBy(delta) - if ( - delta != consumed /* hit the end, stop */ || - coercedValue != value /* would have overshot, stop */ - ) { - cancelAnimation() } - prevValue += delta + val delta = coercedValue - prevValue + debugLog { "Seeking by $delta (coercedValue = $coercedValue)" } + val consumed = scrollBy(delta) + if ( + delta != consumed /* hit the end, stop */ || + coercedValue != value /* would have overshot, stop */ + ) { + cancelAnimation() } - // Once we're finished the animation, snap to the exact position to account for - // rounding error (otherwise we tend to end up with the previous item scrolled the - // tiniest bit onscreen) - // TODO: prevent temporarily scrolling *past* the item - snapToItem(index = index, offset = scrollOffset) + prevValue += delta } + // Once we're finished the animation, snap to the exact position to account for + // rounding error (otherwise we tend to end up with the previous item scrolled the + // tiniest bit onscreen) + // TODO: prevent temporarily scrolling *past* the item + snapToItem(index = index, offset = scrollOffset) } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt index 3eecd568d140c..9a6acc9d25c13 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt @@ -17,6 +17,7 @@ package androidx.compose.foundation.lazy.staggeredgrid import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollableDefaults @@ -29,7 +30,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalGraphicsContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -52,6 +52,8 @@ internal fun LazyStaggeredGrid( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), /** Whether scrolling via the user gestures is allowed. */ userScrollEnabled: Boolean = true, + /** The overscroll effect to render and dispatch events to */ + overscrollEffect: OverscrollEffect?, /** The vertical spacing for items/lines. */ mainAxisSpacing: Dp = 0.dp, /** The horizontal spacing for items/lines. */ @@ -77,12 +79,17 @@ internal fun LazyStaggeredGrid( ) val semanticState = rememberLazyStaggeredGridSemanticState(state, reverseLayout) - val reverseDirection = - ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseLayout - ) + val beyondBoundsModifier = + if (userScrollEnabled) { + Modifier.lazyLayoutBeyondBoundsModifier( + state = rememberLazyStaggeredGridBeyondBoundsState(state = state), + beyondBoundsInfo = state.beyondBoundsInfo, + reverseLayout = reverseLayout, + orientation = orientation, + ) + } else { + Modifier + } LazyLayout( modifier = @@ -96,23 +103,16 @@ internal fun LazyStaggeredGrid( userScrollEnabled = userScrollEnabled, reverseScrolling = reverseLayout, ) - .lazyLayoutBeyondBoundsModifier( - state = rememberLazyStaggeredGridBeyondBoundsState(state = state), - beyondBoundsInfo = state.beyondBoundsInfo, - reverseLayout = reverseLayout, - layoutDirection = LocalLayoutDirection.current, - orientation = orientation, - enabled = userScrollEnabled - ) + .then(beyondBoundsModifier) .then(state.itemAnimator.modifier) .scrollingContainer( state = state, orientation = orientation, enabled = userScrollEnabled, - reverseDirection = reverseDirection, + reverseScrolling = reverseLayout, flingBehavior = flingBehavior, interactionSource = state.mutableInteractionSource, - overscrollEffect = ScrollableDefaults.overscrollEffect() + overscrollEffect = overscrollEffect ), prefetchState = state.prefetchState, itemProvider = itemProviderLambda, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt index edb7f89519d3f..5d7d9a7c7f21f 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.lazy.staggeredgrid +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollableDefaults @@ -24,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -57,6 +59,9 @@ import androidx.compose.ui.unit.dp * @param userScrollEnabled whether scroll with gestures or accessibility actions are allowed. It is * still possible to scroll programmatically through state when [userScrollEnabled] is set to * false + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * layout. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param content a lambda describing the staggered grid content. Inside this block you can use * [LazyStaggeredGridScope.items] to present list of items or [LazyStaggeredGridScope.item] for a * single one. @@ -72,6 +77,7 @@ fun LazyVerticalStaggeredGrid( horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(0.dp), flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyStaggeredGridScope.() -> Unit ) { LazyStaggeredGrid( @@ -84,11 +90,41 @@ fun LazyVerticalStaggeredGrid( crossAxisSpacing = horizontalArrangement.spacing, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, slots = rememberColumnSlots(columns, horizontalArrangement, contentPadding), content = content ) } +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun LazyVerticalStaggeredGrid( + columns: StaggeredGridCells, + modifier: Modifier = Modifier, + state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalItemSpacing: Dp = 0.dp, + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(0.dp), + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyStaggeredGridScope.() -> Unit +) { + LazyVerticalStaggeredGrid( + columns = columns, + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalItemSpacing = verticalItemSpacing, + horizontalArrangement = horizontalArrangement, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), + content = content + ) +} + /** calculates sizes for columns used in staggered grid measure */ @Composable private fun rememberColumnSlots( @@ -148,6 +184,9 @@ private fun rememberColumnSlots( * @param userScrollEnabled whether scroll with gestures or accessibility actions are allowed. It is * still possible to scroll programmatically through state when [userScrollEnabled] is set to * false + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * layout. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param content a lambda describing the staggered grid content. Inside this block you can use * [LazyStaggeredGridScope.items] to present list of items or [LazyStaggeredGridScope.item] for a * single one. @@ -163,6 +202,7 @@ fun LazyHorizontalStaggeredGrid( horizontalItemSpacing: Dp = 0.dp, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), content: LazyStaggeredGridScope.() -> Unit ) { LazyStaggeredGrid( @@ -175,11 +215,41 @@ fun LazyHorizontalStaggeredGrid( crossAxisSpacing = verticalArrangement.spacing, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, + overscrollEffect = overscrollEffect, slots = rememberRowSlots(rows, verticalArrangement, contentPadding), content = content ) } +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun LazyHorizontalStaggeredGrid( + rows: StaggeredGridCells, + modifier: Modifier = Modifier, + state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(0.dp), + horizontalItemSpacing: Dp = 0.dp, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyStaggeredGridScope.() -> Unit +) { + LazyHorizontalStaggeredGrid( + rows = rows, + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalItemSpacing = horizontalItemSpacing, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + overscrollEffect = rememberOverscrollEffect(), + content = content + ) +} + /** calculates sizes for rows used in staggered grid measure */ @Composable private fun rememberRowSlots( diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt index 8bf521c3465d3..1364368253997 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt @@ -18,6 +18,7 @@ package androidx.compose.foundation.lazy.staggeredgrid import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.internal.requirePrecondition +import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimation.Companion.NotInitialized import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimator import androidx.compose.foundation.lazy.layout.LazyLayoutKeyIndexMap import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope @@ -33,6 +34,8 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachReversed @@ -98,6 +101,9 @@ internal fun LazyLayoutMeasureScope.measureStaggeredGrid( beforeContentPadding: Int, afterContentPadding: Int, coroutineScope: CoroutineScope, + isInLookaheadScope: Boolean, + isLookingAhead: Boolean, + approachLayoutInfo: LazyStaggeredGridLayoutInfo?, graphicsContext: GraphicsContext ): LazyStaggeredGridMeasureResult { val context = @@ -116,6 +122,9 @@ internal fun LazyLayoutMeasureScope.measureStaggeredGrid( mainAxisSpacing = mainAxisSpacing, measureScope = this, coroutineScope = coroutineScope, + isInLookaheadScope = isInLookaheadScope, + isLookingAhead = isLookingAhead, + approachLayoutInfo = approachLayoutInfo, graphicsContext = graphicsContext ) @@ -172,7 +181,8 @@ internal fun LazyLayoutMeasureScope.measureStaggeredGrid( } return context.measure( - initialScrollDelta = state.scrollToBeConsumed.fastRoundToInt(), + initialScrollDelta = + state.scrollToBeConsumed(isLookingAhead = isLookingAhead).fastRoundToInt(), initialItemIndices = initialItemIndices, initialItemOffsets = initialItemOffsets, canRestartMeasure = true, @@ -195,6 +205,9 @@ internal class LazyStaggeredGridMeasureContext( val reverseLayout: Boolean, val mainAxisSpacing: Int, val coroutineScope: CoroutineScope, + val isInLookaheadScope: Boolean, + val isLookingAhead: Boolean, + val approachLayoutInfo: LazyStaggeredGridLayoutInfo?, val graphicsContext: GraphicsContext ) { val measuredItemProvider = @@ -273,17 +286,20 @@ private fun LazyStaggeredGridMeasureContext.measure( itemProvider = measuredItemProvider, laneCount = laneCount, isVertical = isVertical, - isLookingAhead = false, - hasLookaheadOccurred = false, + isLookingAhead = isLookingAhead, + hasLookaheadOccurred = isInLookaheadScope, layoutMinOffset = 0, layoutMaxOffset = 0, coroutineScope = coroutineScope, graphicsContext = graphicsContext ) - val disappearingItemsSize = state.itemAnimator.minSizeToFitDisappearingItems - if (disappearingItemsSize != IntSize.Zero) { - layoutWidth = constraints.constrainWidth(disappearingItemsSize.width) - layoutHeight = constraints.constrainHeight(disappearingItemsSize.height) + + if (!isLookingAhead) { + val disappearingItemsSize = state.itemAnimator.minSizeToFitDisappearingItems + if (disappearingItemsSize != IntSize.Zero) { + layoutWidth = constraints.constrainWidth(disappearingItemsSize.width) + layoutHeight = constraints.constrainHeight(disappearingItemsSize.height) + } } return LazyStaggeredGridMeasureResult( firstVisibleItemIndices = initialItemIndices, @@ -304,6 +320,7 @@ private fun LazyStaggeredGridMeasureContext.measure( slots = resolvedSlots, spanProvider = itemProvider.spanProvider, density = this, + scrollBackAmount = 0f, coroutineScope = coroutineScope ) } @@ -607,6 +624,7 @@ private fun LazyStaggeredGridMeasureContext.measure( "offsets: ${firstItemOffsets.toList()}" } + val preScrollBackScrollDelta = scrollDelta // we didn't fill the whole viewport with items starting from firstVisibleItemIndex. // lets try to scroll back if we have enough items before firstVisibleItemIndex. if (currentItemOffsets.all { it < mainAxisAvailableSize }) { @@ -715,16 +733,25 @@ private fun LazyStaggeredGridMeasureContext.measure( // scrollToBeConsumed if there were not enough items to fill the offered space or it // can be larger if items were resized, or if, for example, we were previously // displaying the item 15, but now we have only 10 items in total in the data set. + val scrollToBeConsumed = state.scrollToBeConsumed(isLookingAhead) val consumedScroll = if ( - state.scrollToBeConsumed.fastRoundToInt().sign == scrollDelta.sign && - abs(state.scrollToBeConsumed.fastRoundToInt()) >= abs(scrollDelta) + scrollToBeConsumed.fastRoundToInt().sign == scrollDelta.sign && + abs(scrollToBeConsumed.fastRoundToInt()) >= abs(scrollDelta) ) { scrollDelta.toFloat() } else { - state.scrollToBeConsumed + scrollToBeConsumed } + val unconsumedScroll = scrollToBeConsumed - consumedScroll + // When scrolling to the bottom via gesture, there could be scrollback due to + // not being able to consume the whole scroll. In that case, the amount of + // scrollBack is the inverse of unconsumed scroll. + val scrollBackAmount: Float = + if (isLookingAhead && scrollDelta > preScrollBackScrollDelta && unconsumedScroll <= 0) { + scrollDelta - preScrollBackScrollDelta + unconsumedScroll + } else 0f val itemScrollOffsets = firstItemOffsets.copyOf().transform { -it } // even if we compose items to fill before content padding we should ignore items fully @@ -829,6 +856,21 @@ private fun LazyStaggeredGridMeasureContext.measure( ) extraItemOffset = itemScrollOffsets[0] + + val itemsRetainedForLookahead = + itemsRetainedForLookahead( + lastVisibleItemIndex = visibleItems.lastOrNull()?.index ?: -1, + itemCount, + isLookingAhead, + position = { item, crossAxis -> + item.position( + mainAxis = extraItemOffset, + crossAxis = crossAxis, + mainAxisLayoutSize = mainAxisLayoutSize + ) + extraItemOffset += item.mainAxisSizeWithSpacings + } + ) val extraItemsAfter = calculateExtraItems( position = { @@ -843,6 +885,10 @@ private fun LazyStaggeredGridMeasureContext.measure( if (itemIndex >= itemCount) { return@calculateExtraItems false } + // Also filter out items already in itemsRetainedForLookahead + if (itemsRetainedForLookahead?.fastAny { it.index == itemIndex } == true) { + return@calculateExtraItems false + } val lane = laneInfo.getLane(itemIndex) when (lane) { Unset, @@ -864,6 +910,9 @@ private fun LazyStaggeredGridMeasureContext.measure( val positionedItems = mutableListOf() positionedItems.addAll(extraItemsBefore) positionedItems.addAll(visibleItems) + if (itemsRetainedForLookahead != null) { + positionedItems.addAll(itemsRetainedForLookahead) + } positionedItems.addAll(extraItemsAfter) debugLog { "positioned: $positionedItems" } @@ -877,24 +926,26 @@ private fun LazyStaggeredGridMeasureContext.measure( itemProvider = measuredItemProvider, isVertical = isVertical, laneCount = laneCount, - isLookingAhead = false, - hasLookaheadOccurred = false, + isLookingAhead = isLookingAhead, + hasLookaheadOccurred = isInLookaheadScope, layoutMinOffset = firstItemOffsets.min(), layoutMaxOffset = currentItemOffsets.max() + contentPadding, coroutineScope = coroutineScope, graphicsContext = graphicsContext ) - val disappearingItemsSize = state.itemAnimator.minSizeToFitDisappearingItems - if (disappearingItemsSize != IntSize.Zero) { - val oldMainAxisSize = if (isVertical) layoutHeight else layoutWidth - layoutWidth = - constraints.constrainWidth(maxOf(layoutWidth, disappearingItemsSize.width)) - layoutHeight = - constraints.constrainHeight(maxOf(layoutHeight, disappearingItemsSize.height)) - val newMainAxisSize = if (isVertical) layoutHeight else layoutWidth - if (newMainAxisSize != oldMainAxisSize) { - positionedItems.fastForEach { it.updateMainAxisLayoutSize(newMainAxisSize) } + if (!isLookingAhead) { + val disappearingItemsSize = state.itemAnimator.minSizeToFitDisappearingItems + if (disappearingItemsSize != IntSize.Zero) { + val oldMainAxisSize = if (isVertical) layoutHeight else layoutWidth + layoutWidth = + constraints.constrainWidth(maxOf(layoutWidth, disappearingItemsSize.width)) + layoutHeight = + constraints.constrainHeight(maxOf(layoutHeight, disappearingItemsSize.height)) + val newMainAxisSize = if (isVertical) layoutHeight else layoutWidth + if (newMainAxisSize != oldMainAxisSize) { + positionedItems.fastForEach { it.updateMainAxisLayoutSize(newMainAxisSize) } + } } } @@ -918,7 +969,7 @@ private fun LazyStaggeredGridMeasureContext.measure( // animating, to avoid a chasing effect to scrolling. withMotionFrameOfReferencePlacement { positionedItems.fastForEach { item -> - item.place(scope = this, context = this@measure) + item.place(scope = this, context = this@measure, isLookingAhead) } } @@ -926,6 +977,7 @@ private fun LazyStaggeredGridMeasureContext.measure( // re-placement state.placementScopeInvalidator.attachToScope() }, + scrollBackAmount = scrollBackAmount, canScrollForward = canScrollForward, isVertical = isVertical, visibleItemsInfo = visibleItems, @@ -984,6 +1036,65 @@ private fun LazyStaggeredGridMeasureContext.calculateVisibleItems( return positionedItems } +/** + * Retain the items from last approach pass until they are no longer needed in the approach. This + * avoids disposing items in lookahead too early, which would lead to the items being composed in a + * different node later in the approach and lose all its internal states. + */ +private inline fun LazyStaggeredGridMeasureContext.itemsRetainedForLookahead( + lastVisibleItemIndex: Int, + itemsCount: Int, + isLookingAhead: Boolean, + position: (LazyStaggeredGridMeasuredItem, Int) -> Unit +): List? { + var list: MutableList? = null + + if (isLookingAhead) { + // Check if there's any item that needs to be composed based on last approachLayoutInfo + if (approachLayoutInfo != null && approachLayoutInfo.visibleItemsInfo.isNotEmpty()) { + // Find first item with index > end. Note that `visibleItemsInfo.last()` may not have + // the largest index as the last few items could be added to animate item placement. + val firstItem = + approachLayoutInfo.visibleItemsInfo.run { + var found: LazyStaggeredGridItemInfo? = null + for (i in size - 1 downTo 0) { + if ( + this[i].index > lastVisibleItemIndex && + (i == 0 || this[i - 1].index <= lastVisibleItemIndex) + ) { + found = this[i] + break + } + } + found + } + val lastVisibleItem = approachLayoutInfo.visibleItemsInfo.last() + if (firstItem != null) { + for (i in firstItem.index..min(lastVisibleItem.index, itemsCount - 1)) { + if (list?.fastAny { it.index == i } != true) { + if (list == null) list = mutableListOf() + val lane = + approachLayoutInfo.visibleItemsInfo + .fastFirstOrNull { it.index == i } + ?.lane ?: 0 + val spanRange = itemProvider.getSpanRange(i, lane) + val item = measuredItemProvider.getAndMeasure(i, spanRange) + list.add(item) + val crossAxisOffset = + resolvedSlots.positions.let { if (it.size > lane) it[lane] else 0 } + // Position items that are no longer in the lookahead based on their + // last seen crossAxisOffset, so their position animation for disappearance + // will only have motion along the mainAxis, which produces a more + // pleasant and less chaotic overall look. + position(item, crossAxisOffset) + } + } + } + } + } + return list +} + private inline fun LazyStaggeredGridMeasureContext.calculateExtraItems( position: (LazyStaggeredGridMeasuredItem) -> Unit, filter: (itemIndex: Int) -> Boolean, @@ -1256,7 +1367,11 @@ internal class LazyStaggeredGridMeasuredItem( val mainAxisOffset get() = if (!isVertical) offset.x else offset.y - fun place(scope: Placeable.PlacementScope, context: LazyStaggeredGridMeasureContext) = + fun place( + scope: Placeable.PlacementScope, + context: LazyStaggeredGridMeasureContext, + isLookingAhead: Boolean + ) = with(context) { requirePrecondition(mainAxisLayoutSize != Unset) { "position() should be called first" } with(scope) { @@ -1268,18 +1383,30 @@ internal class LazyStaggeredGridMeasuredItem( val animation = animator.getAnimation(key, index) val layer: GraphicsLayer? if (animation != null) { - val animatedOffset = offset + animation.placementDelta - // cancel the animation if current and target offsets are both out of the - // bounds. - if ( - (offset.mainAxis <= minOffset && - animatedOffset.mainAxis <= minOffset) || - (offset.mainAxis >= maxOffset && - animatedOffset.mainAxis >= maxOffset) - ) { - animation.cancelPlacementAnimation() + if (isLookingAhead) { + // Skip animation in lookahead pass + animation.lookaheadOffset = offset + } else { + val targetOffset = + if (animation.lookaheadOffset != NotInitialized) { + animation.lookaheadOffset + } else { + offset + } + val animatedOffset = targetOffset + animation.placementDelta + // cancel the animation if current and target offsets are both out of + // the + // bounds. + if ( + (offset.mainAxis <= minOffset && + animatedOffset.mainAxis <= minOffset) || + (offset.mainAxis >= maxOffset && + animatedOffset.mainAxis >= maxOffset) + ) { + animation.cancelPlacementAnimation() + } + offset = animatedOffset } - offset = animatedOffset layer = animation.layer } else { layer = null @@ -1291,7 +1418,9 @@ internal class LazyStaggeredGridMeasuredItem( } } offset += contentOffset - animation?.finalOffset = offset + if (!isLookingAhead) { + animation?.finalOffset = offset + } if (layer != null) { placeable.placeRelativeWithLayer(offset, layer) } else { @@ -1310,15 +1439,17 @@ internal class LazyStaggeredGridMeasuredItem( maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding } - fun applyScrollDelta(delta: Int) { + fun applyScrollDelta(delta: Int, updateAnimations: Boolean) { if (nonScrollableItem) { return } offset = offset.copy { it + delta } - repeat(placeablesCount) { index -> - val animation = animator.getAnimation(key, index) - if (animation != null) { - animation.rawOffset = animation.rawOffset.copy { mainAxis -> mainAxis + delta } + if (updateAnimations) { + repeat(placeablesCount) { index -> + val animation = animator.getAnimation(key, index) + if (animation != null) { + animation.rawOffset = animation.rawOffset.copy { mainAxis -> mainAxis + delta } + } } } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt index 7fa53f8655e49..8470006551a73 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt @@ -62,6 +62,8 @@ internal fun rememberStaggeredGridMeasurePolicy( ) { { constraints -> state.measurementScopeInvalidator.attachToScope() + // Tracks if the lookahead pass has occurred + val isInLookaheadScope = state.hasLookaheadOccurred || isLookingAhead checkScrollableContainerConstraints(constraints, orientation) val resolvedSlots = slots.invoke(density = this, constraints = constraints) val isVertical = orientation == Orientation.Vertical @@ -122,9 +124,12 @@ internal fun rememberStaggeredGridMeasurePolicy( beforeContentPadding = beforeContentPadding, afterContentPadding = afterContentPadding, coroutineScope = coroutineScope, + isInLookaheadScope = isInLookaheadScope, + isLookingAhead = isLookingAhead, + approachLayoutInfo = state.approachLayoutInfo, graphicsContext = graphicsContext ) - state.applyMeasureResult(measureResult) + state.applyMeasureResult(measureResult, isLookingAhead = isLookingAhead) measureResult } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasureResult.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasureResult.kt index 89174a1ad6bd4..85ec1fc1b7746 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasureResult.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasureResult.kt @@ -123,6 +123,8 @@ internal class LazyStaggeredGridMeasureResult( val firstVisibleItemScrollOffsets: IntArray, val consumedScroll: Float, val measureResult: MeasureResult, + /** The amount of scroll-back that happened due to reaching the end of the list. */ + val scrollBackAmount: Float, val canScrollForward: Boolean, val isVertical: Boolean, /** True when extra remeasure is required. */ @@ -159,7 +161,10 @@ internal class LazyStaggeredGridMeasureResult( * If If new layout info is returned, only the placement phase is needed to apply new offsets. * If null is returned, it means we have to rerun the full measure phase to apply the [delta]. */ - fun copyWithScrollDeltaWithoutRemeasure(delta: Int): LazyStaggeredGridMeasureResult? { + fun copyWithScrollDeltaWithoutRemeasure( + delta: Int, + updateAnimations: Boolean + ): LazyStaggeredGridMeasureResult? { if ( remeasureNeeded || visibleItemsInfo.isEmpty() || @@ -203,7 +208,7 @@ internal class LazyStaggeredGridMeasureResult( if (!canApply) return null } } - visibleItemsInfo.fastForEach { it.applyScrollDelta(delta) } + visibleItemsInfo.fastForEach { it.applyScrollDelta(delta, updateAnimations) } return LazyStaggeredGridMeasureResult( firstVisibleItemIndices = firstVisibleItemIndices, firstVisibleItemScrollOffsets = @@ -211,6 +216,7 @@ internal class LazyStaggeredGridMeasureResult( firstVisibleItemScrollOffsets[index] - delta }, consumedScroll = delta.toFloat(), + scrollBackAmount = scrollBackAmount, measureResult = measureResult, canScrollForward = canScrollForward || delta > 0, // we scrolled backward, so now we can scroll forward @@ -262,5 +268,6 @@ internal val EmptyLazyStaggeredGridLayoutInfo = slots = LazyStaggeredGridSlots(EmptyArray, EmptyArray), spanProvider = LazyStaggeredGridSpanProvider(MutableIntervalList()), density = Density(1f), + scrollBackAmount = 0f, coroutineScope = CoroutineScope(EmptyCoroutineContext) ) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt index f447666700320..8b1e8c688c53d 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState.PrefetchHandle +import androidx.compose.foundation.lazy.layout.LazyLayoutScrollDeltaBetweenPasses import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator import androidx.compose.foundation.lazy.layout.PrefetchScheduler import androidx.compose.foundation.lazy.layout.animateScrollToItem @@ -102,6 +103,12 @@ internal constructor( null ) + internal var hasLookaheadOccurred: Boolean = false + private set + + internal var approachLayoutInfo: LazyStaggeredGridMeasureResult? = null + private set + /** * Index of the first visible item across all staggered grid lanes. This does not include items * in the content padding region. For the first visible item that includes items in the content @@ -190,8 +197,14 @@ internal constructor( private val scrollableState = ScrollableState { -onScroll(-it) } /** scroll to be consumed during next/current layout pass */ - internal var scrollToBeConsumed = 0f - private set + private var scrollToBeConsumed = 0f + + internal fun scrollToBeConsumed(isLookingAhead: Boolean): Float = + if (isLookingAhead || !hasLookaheadOccurred) { + scrollToBeConsumed + } else { + scrollDeltaBetweenPasses + } /* @VisibleForTesting */ internal var measurePassCount = 0 @@ -262,10 +275,33 @@ internal constructor( if (abs(scrollToBeConsumed) > 0.5f) { val preScrollToBeConsumed = scrollToBeConsumed val intDelta = scrollToBeConsumed.roundToInt() - val scrolledLayoutInfo = - layoutInfoState.value.copyWithScrollDeltaWithoutRemeasure(delta = intDelta) + var scrolledLayoutInfo = + layoutInfoState.value.copyWithScrollDeltaWithoutRemeasure( + delta = intDelta, + updateAnimations = !hasLookaheadOccurred + ) + if (scrolledLayoutInfo != null && this.approachLayoutInfo != null) { + // if we were able to scroll the lookahead layout info without remeasure, lets + // try to do the same for post lookahead layout info (sometimes they diverge). + val scrolledApproachLayoutInfo = + approachLayoutInfo?.copyWithScrollDeltaWithoutRemeasure( + delta = intDelta, + updateAnimations = true + ) + if (scrolledApproachLayoutInfo != null) { + // we can apply scroll delta for both phases without remeasure + approachLayoutInfo = scrolledApproachLayoutInfo + } else { + // we can't apply scroll delta for post lookahead, so we have to remeasure + scrolledLayoutInfo = null + } + } if (scrolledLayoutInfo != null) { - applyMeasureResult(result = scrolledLayoutInfo, visibleItemsStayedTheSame = true) + applyMeasureResult( + result = scrolledLayoutInfo, + isLookingAhead = hasLookaheadOccurred, + visibleItemsStayedTheSame = true + ) // we don't need to remeasure, so we only trigger re-placement: placementScopeInvalidator.invalidateScope() @@ -325,13 +361,7 @@ internal constructor( val numOfItemsToTeleport = 100 * layoutInfo.slots.sizes.size scroll { LazyLayoutScrollScope(this@LazyStaggeredGridState, this) - .animateScrollToItem( - index, - scrollOffset, - numOfItemsToTeleport, - layoutInfo.density, - this - ) + .animateScrollToItem(index, scrollOffset, numOfItemsToTeleport, layoutInfo.density) } } @@ -511,23 +541,44 @@ internal constructor( /** updates state after measure pass */ internal fun applyMeasureResult( result: LazyStaggeredGridMeasureResult, + isLookingAhead: Boolean, visibleItemsStayedTheSame: Boolean = false ) { - scrollToBeConsumed -= result.consumedScroll - layoutInfoState.value = result - - if (visibleItemsStayedTheSame) { - scrollPosition.updateScrollOffset(result.firstVisibleItemScrollOffsets) + if (!isLookingAhead && hasLookaheadOccurred) { + // If there was already a lookahead pass, record this result as Approach result + approachLayoutInfo = result } else { - scrollPosition.updateFromMeasureResult(result) - cancelPrefetchIfVisibleItemsChanged(result) - } - canScrollBackward = result.canScrollBackward - canScrollForward = result.canScrollForward + if (isLookingAhead) { + hasLookaheadOccurred = true + } + scrollToBeConsumed -= result.consumedScroll + layoutInfoState.value = result - measurePassCount++ + if (visibleItemsStayedTheSame) { + scrollPosition.updateScrollOffset(result.firstVisibleItemScrollOffsets) + } else { + scrollPosition.updateFromMeasureResult(result) + cancelPrefetchIfVisibleItemsChanged(result) + } + canScrollBackward = result.canScrollBackward + canScrollForward = result.canScrollForward + + if (isLookingAhead) { + _lazyLayoutScrollDeltaBetweenPasses.updateScrollDeltaForApproach( + result.scrollBackAmount, + result.density, + result.coroutineScope + ) + } + measurePassCount++ + } } + internal val scrollDeltaBetweenPasses: Float + get() = _lazyLayoutScrollDeltaBetweenPasses.scrollDeltaBetweenPasses + + private val _lazyLayoutScrollDeltaBetweenPasses = LazyLayoutScrollDeltaBetweenPasses() + private fun fillNearestIndices(itemIndex: Int, laneCount: Int): IntArray { val indices = IntArray(laneCount) if (layoutInfoState.value.spanProvider.isFullSpan(itemIndex)) { diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt index ad3dacad86233..0b5d584d55dc2 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt @@ -19,12 +19,12 @@ package androidx.compose.foundation.pager import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.BringIntoViewSpec import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollScope -import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.TargetedFlingBehavior import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -58,7 +58,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAll @@ -82,6 +81,8 @@ internal fun Pager( flingBehavior: TargetedFlingBehavior, /** Whether scrolling via the user gestures is allowed. */ userScrollEnabled: Boolean, + /** The overscroll effect to render and dispatch events to */ + overscrollEffect: OverscrollEffect?, /** Number of pages to compose and layout before and after the visible pages */ beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount, /** Space between pages */ @@ -141,12 +142,21 @@ internal fun Pager( PagerBringIntoViewSpec(state, defaultBringIntoViewSpec) } - val reverseDirection = - ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseLayout - ) + val beyondBoundsModifier = + if (userScrollEnabled) { + Modifier.lazyLayoutBeyondBoundsModifier( + state = + rememberPagerBeyondBoundsState( + state = state, + beyondViewportPageCount = beyondViewportPageCount + ), + beyondBoundsInfo = state.beyondBoundsInfo, + reverseLayout = reverseLayout, + orientation = orientation, + ) + } else { + Modifier + } LazyLayout( modifier = @@ -166,27 +176,16 @@ internal fun Pager( coroutineScope, userScrollEnabled ) - .lazyLayoutBeyondBoundsModifier( - state = - rememberPagerBeyondBoundsState( - state = state, - beyondViewportPageCount = beyondViewportPageCount - ), - beyondBoundsInfo = state.beyondBoundsInfo, - reverseLayout = reverseLayout, - layoutDirection = LocalLayoutDirection.current, - orientation = orientation, - enabled = userScrollEnabled - ) + .then(beyondBoundsModifier) .scrollingContainer( state = state, orientation = orientation, enabled = userScrollEnabled, - reverseDirection = reverseDirection, + reverseScrolling = reverseLayout, flingBehavior = resolvedFlingBehavior, interactionSource = state.internalInteractionSource, - bringIntoViewSpec = pagerBringIntoViewSpec, - overscrollEffect = ScrollableDefaults.overscrollEffect() + overscrollEffect = overscrollEffect, + bringIntoViewSpec = pagerBringIntoViewSpec ) .dragDirectionDetector(state) .nestedScroll(pageNestedScrollConnection), diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt index 6fdeab6f11076..1d589531e0b75 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt @@ -23,6 +23,7 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.TargetedFlingBehavior @@ -32,6 +33,7 @@ import androidx.compose.foundation.gestures.snapping.calculateFinalSnappingBound import androidx.compose.foundation.gestures.snapping.snapFlingBehavior import androidx.compose.foundation.internal.requirePrecondition import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -96,6 +98,9 @@ import kotlinx.coroutines.launch * way to calculate [PagerState.currentPage], currentPage is the page closest to the snap position * in the layout (e.g. if the snap position is the start of the layout, then currentPage will be * the page closest to that). + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * Pager. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param pageContent This Pager's page Composable. * @sample androidx.compose.foundation.samples.SimpleHorizontalPagerSample * @sample androidx.compose.foundation.samples.HorizontalPagerWithScrollableContent @@ -120,6 +125,7 @@ fun HorizontalPager( pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(state, Orientation.Horizontal), snapPosition: SnapPosition = SnapPosition.Start, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), pageContent: @Composable PagerScope.(page: Int) -> Unit ) { Pager( @@ -138,6 +144,45 @@ fun HorizontalPager( key = key, pageNestedScrollConnection = pageNestedScrollConnection, snapPosition = snapPosition, + overscrollEffect = overscrollEffect, + pageContent = pageContent + ) +} + +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun HorizontalPager( + state: PagerState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + pageSize: PageSize = PageSize.Fill, + beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount, + pageSpacing: Dp = 0.dp, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + flingBehavior: TargetedFlingBehavior = PagerDefaults.flingBehavior(state = state), + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + key: ((index: Int) -> Any)? = null, + pageNestedScrollConnection: NestedScrollConnection = + PagerDefaults.pageNestedScrollConnection(state, Orientation.Horizontal), + snapPosition: SnapPosition = SnapPosition.Start, + pageContent: @Composable PagerScope.(page: Int) -> Unit +) { + HorizontalPager( + state = state, + modifier = modifier, + contentPadding = contentPadding, + pageSize = pageSize, + beyondViewportPageCount = beyondViewportPageCount, + pageSpacing = pageSpacing, + verticalAlignment = verticalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + key = key, + pageNestedScrollConnection = pageNestedScrollConnection, + snapPosition = snapPosition, + overscrollEffect = rememberOverscrollEffect(), pageContent = pageContent ) } @@ -184,6 +229,9 @@ fun HorizontalPager( * way to calculate [PagerState.currentPage], currentPage is the page closest to the snap position * in the layout (e.g. if the snap position is the start of the layout, then currentPage will be * the page closest to that). + * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this + * Pager. Note that the [OverscrollEffect.node] will be applied internally as well - you do not + * need to use Modifier.overscroll separately. * @param pageContent This Pager's page Composable. * @sample androidx.compose.foundation.samples.SimpleVerticalPagerSample * @see androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider for the implementation @@ -207,6 +255,7 @@ fun VerticalPager( pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(state, Orientation.Vertical), snapPosition: SnapPosition = SnapPosition.Start, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), pageContent: @Composable PagerScope.(page: Int) -> Unit ) { Pager( @@ -225,6 +274,45 @@ fun VerticalPager( key = key, pageNestedScrollConnection = pageNestedScrollConnection, snapPosition = snapPosition, + overscrollEffect = overscrollEffect, + pageContent = pageContent + ) +} + +@Deprecated("Use the non deprecated overload", level = DeprecationLevel.HIDDEN) +@Composable +fun VerticalPager( + state: PagerState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + pageSize: PageSize = PageSize.Fill, + beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount, + pageSpacing: Dp = 0.dp, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + flingBehavior: TargetedFlingBehavior = PagerDefaults.flingBehavior(state = state), + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + key: ((index: Int) -> Any)? = null, + pageNestedScrollConnection: NestedScrollConnection = + PagerDefaults.pageNestedScrollConnection(state, Orientation.Vertical), + snapPosition: SnapPosition = SnapPosition.Start, + pageContent: @Composable PagerScope.(page: Int) -> Unit +) { + VerticalPager( + state = state, + modifier = modifier, + contentPadding = contentPadding, + pageSize = pageSize, + beyondViewportPageCount = beyondViewportPageCount, + pageSpacing = pageSpacing, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + key = key, + pageNestedScrollConnection = pageNestedScrollConnection, + snapPosition = snapPosition, + overscrollEffect = rememberOverscrollEffect(), pageContent = pageContent ) } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt index 22536437ae738..1b849ddcbed55 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt @@ -863,7 +863,8 @@ private inline fun debugLog(generateMsg: () -> String) { internal fun PagerLayoutInfo.calculateNewMaxScrollOffset(pageCount: Int): Long { val pageSizeWithSpacing = pageSpacing + pageSize val maxScrollPossible = - (pageCount.toLong()) * pageSizeWithSpacing + beforeContentPadding + afterContentPadding + (pageCount.toLong()) * pageSizeWithSpacing + beforeContentPadding + afterContentPadding - + pageSpacing val layoutSize = if (orientation == Orientation.Horizontal) viewportSize.width else viewportSize.height diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.js.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/platform/Synchronization.kt similarity index 56% rename from compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.js.kt rename to compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/platform/Synchronization.kt index 808d8435aa563..b5d86191e6975 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.js.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/platform/Synchronization.kt @@ -14,12 +14,15 @@ * limitations under the License. */ -package androidx.compose.ui.hapticfeedback +package androidx.compose.foundation.platform + +internal expect class SynchronizedObject /** - * Desktop implementation for [HapticFeedbackType] + * Returns [ref] as a [SynchronizedObject] on platforms where [Any] is a valid [SynchronizedObject], + * or a new [SynchronizedObject] instance if [ref] is null or this is not supported on the current + * platform. */ -internal actual object PlatformHapticFeedbackType { - actual val LongPress: HapticFeedbackType = HapticFeedbackType(0) - actual val TextHandleMove: HapticFeedbackType = HapticFeedbackType(9) -} \ No newline at end of file +internal expect inline fun makeSynchronizedObject(ref: Any? = null): SynchronizedObject + +internal expect inline fun synchronized(lock: SynchronizedObject, block: () -> R): R diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutoSize.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutoSize.kt index 98a3f2c0fe5cd..450cbc31bfffe 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutoSize.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutoSize.kt @@ -16,48 +16,20 @@ package androidx.compose.foundation.text -import androidx.compose.ui.unit.Density +import androidx.compose.foundation.text.modifiers.AutoSizeTextLayoutScope import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.sp import kotlin.math.floor -/** Interface used by Text composables to automatically size their text. */ -internal interface AutoSize { - /** - * Calculates font size. Use utility function [FontSizeSearchScope.performLayoutAndGetOverflow] - * to lay out the text and check if it overflows. The expectation is that - * implementation-specific constraints should be used in unison with - * [FontSizeSearchScope.performLayoutAndGetOverflow] to determine a suitable font size to be - * used. - * - * @return The derived optimal font size. - * @see [FontSizeSearchScope.performLayoutAndGetOverflow] - */ - // TODO(b/362904946): Add sample - fun FontSizeSearchScope.getFontSize(): TextUnit - - /** - * Require equals() to be implemented for performance purposes. Using a data class is - * sufficient. Singletons may implement this function with referential equality (`this === - * other`). Instances with no properties may implement this function by checking the type of the - * other object. - * - * @return true if both AutoSize instances are structurally identical. - */ - override fun equals(other: Any?): Boolean - +/** + * Interface used by Text composables to override text size to automatically grow or shrink text to + * fill the layout bounds. + */ +sealed interface AutoSize { companion object { /** - * Automatically size the text to attempt to fit its container. This uses a - * step-based/granular implementation where potential font sizes are uniformly spread out - * between [minFontSize] and [maxFontSize]. [stepSize] is the smallest difference between - * two distinct font sizes. e.g. if `minFontSize = 1.sp`, `maxFontSize = 2.sp` and `stepSize - * = 0.5.sp`, the potential font sizes are `1.sp`, `1.5.sp`, and `2.sp`. In cases where - * [stepSize] is strictly greater than (not equal to) the difference between [minFontSize] - * and [maxFontSize], the only potential font size is [minFontSize]. - * - * Both or neither [minFontSize] and [maxFontSize] must be declared. + * Automatically size the text with the biggest font size that fits the available space. * * @param minFontSize The smallest potential font size of the text. Default = 12.sp. This * must be smaller than [maxFontSize]; an [IllegalArgumentException] will be thrown @@ -68,67 +40,70 @@ internal interface AutoSize { * @param stepSize The smallest difference between potential font sizes. Specifically, every * font size, when subtracted by [minFontSize], is divisible by [stepSize]. Default = * 0.25.sp. This must not be less than `0.0001f.sp`; an [IllegalArgumentException] will be - * thrown otherwise. + * thrown otherwise. If [stepSize] is greater than the difference between [minFontSize] + * and [maxFontSize], [minFontSize] will be used for the layout. * @return AutoSize instance with the step-based configuration. Using this in a compatible * composable will cause its text to be sized as above. */ fun StepBased( - minFontSize: TextUnit, - maxFontSize: TextUnit, + minFontSize: TextUnit = AutoSizeDefaults.MinFontSize, + maxFontSize: TextUnit = AutoSizeDefaults.MaxFontSize, stepSize: TextUnit = 0.25.sp - ): AutoSize { - return AutoSizeStepBased(minFontSize, maxFontSize, stepSize) - } - - /** - * Automatically size the text to attempt to fit its container. This uses a - * step-based/granular implementation where potential font sizes are uniformly spread out - * between `minFontSize` and `maxFontSize`. [stepSize] is the smallest difference between - * two distinct font sizes. e.g. if `minFontSize = 1.sp`, `maxFontSize = 2.sp` and `stepSize - * = 0.5.sp`, the potential font sizes are `1.sp`, `1.5.sp`, and `2.sp`. In cases where - * [stepSize] is strictly greater than (not equal to) the difference between `minFontSize` - * and `maxFontSize`, the only potential font size is `minFontSize`. - * - * Both or neither `minFontSize` and `maxFontSize` must be declared. - * - * @param stepSize The smallest difference between potential font sizes. Specifically, every - * font size, when subtracted by `minFontSize` (`12.sp`), is divisible by [stepSize]. - * Default = 0.25.sp. [stepSize] must not be less than `0.0001f.sp`, an - * [IllegalArgumentException] will be thrown otherwise. - * @return AutoSize instance with the step-based configuration. Using this in a compatible - * composable will cause its text to be sized as above. - */ - fun StepBased(stepSize: TextUnit = 0.25.sp): AutoSize { - return AutoSizeStepBased(minFontSize = 12.sp, maxFontSize = 112.sp, stepSize = stepSize) - } + ): AutoSize = + AutoSizeStepBased( + minFontSize = minFontSize, + maxFontSize = maxFontSize, + stepSize = stepSize + ) } } -/** - * This interface is used by classes responsible for laying out text. Layout will be performed here - * alongside logic that checks if the text overflows. - * - * These methods are used by [AutoSize] in the [AutoSize.getFontSize] method, where developers can - * lay out text with different font sizes and do certain logic depending on whether or not the text - * overflows. - * - * This may be implemented in unit tests when testing [AutoSize.getFontSize] to see if the method - * works as intended. - */ -internal interface FontSizeSearchScope : Density { +/** Contains defaults for [AutoSize] APIs. */ +object AutoSizeDefaults { + /** The default minimum font size for [AutoSize]. */ + val MinFontSize = 12.sp + + /** The default maximum font size for [AutoSize]. */ + val MaxFontSize = 112.sp +} + +internal interface TextAutoSize : AutoSize { + /** + * Calculates font size. Use utility function + * [AutoSizeTextLayoutScope.performLayoutAndGetOverflow] to lay out the text and check if it + * overflows. The expectation is that implementation-specific constraints should be used in + * unison with [AutoSizeTextLayoutScope.performLayoutAndGetOverflow] to determine a suitable + * font size to be used. + * + * @return The derived optimal font size. + * @see [AutoSizeTextLayoutScope.performLayoutAndGetOverflow] + */ + // TODO(b/362904946): Add sample + fun AutoSizeTextLayoutScope.getFontSize(): TextUnit + + /** + * This type is used in performance-sensitive paths and requires providing equality guarantees. + * Using a data class is sufficient. Singletons may implement this function with referential + * equality (`this === other`). Instances with no properties may implement this function by + * checking the type of the other object. + * + * @return true if both AutoSize instances are identical. + */ + override fun equals(other: Any?): Boolean + /** - * Lays out the text with the given font size. + * This type is used in performance-sensitive paths and requires providing identity guarantees. * - * @return true if the text overflows. + * @return a unique hashcode for this AutoSize instance. */ - fun performLayoutAndGetOverflow(fontSize: TextUnit): Boolean + override fun hashCode(): Int } private class AutoSizeStepBased( private var minFontSize: TextUnit, private val maxFontSize: TextUnit, private val stepSize: TextUnit -) : AutoSize { +) : TextAutoSize { init { // Checks for validity of AutoSize instance // Unspecified check @@ -172,7 +147,7 @@ private class AutoSizeStepBased( } } - override fun FontSizeSearchScope.getFontSize(): TextUnit { + override fun AutoSizeTextLayoutScope.getFontSize(): TextUnit { val stepSize = stepSize.toPx() val smallest = minFontSize.toPx() val largest = maxFontSize.toPx() diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt new file mode 100644 index 0000000000000..76724911cbd48 --- /dev/null +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt @@ -0,0 +1,40 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import kotlin.jvm.JvmInline + +/** + * Represents the colors used for text selection by text and text field components. + * + * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy. + * + * @property autofillHighlightColor the color used to draw the background behind autofilled + * elements. + */ +@JvmInline +expect value class AutofillHighlight(val autofillHighlightColor: Color) { + companion object { + /** Default instance of [AutofillHighlight]. */ + val Default: AutofillHighlight + } +} + +/** CompositionLocal used to change the [AutofillHighlight] used by components in the hierarchy. */ +val LocalAutofillHighlight = compositionLocalOf { AutofillHighlight.Default } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicSecureTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicSecureTextField.kt index f5af5a5248081..cba0dc03c703a 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicSecureTextField.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicSecureTextField.kt @@ -303,14 +303,16 @@ private fun DisableCutCopy(content: @Composable () -> Unit) { onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, onCutRequested: (() -> Unit)?, - onSelectAllRequested: (() -> Unit)? + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)? ) { currentToolbar.showMenu( rect = rect, onPasteRequested = onPasteRequested, onSelectAllRequested = onSelectAllRequested, onCopyRequested = null, - onCutRequested = null + onCutRequested = null, + onAutofillRequested = onAutofillRequested ) } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt index f89cc15b018cb..56e202555d15c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt @@ -84,6 +84,10 @@ import kotlin.math.floor * @param minLines The minimum height in terms of minimum number of visible lines. It is required * that 1 <= [minLines] <= [maxLines]. * @param color Overrides the text color provided in [style] + * @param autoSize Enable auto sizing for this text composable. Finds the biggest font size that + * fits in the available space and lays the text out with this size. This performs multiple layout + * passes and can be slower than using a fixed font size. This takes precedence over sizes defined + * through [style]. */ @Composable fun BasicText( @@ -95,7 +99,8 @@ fun BasicText( softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, - color: ColorProducer? = null + color: ColorProducer? = null, + autoSize: AutoSize? = null ) { validateMinMaxLines(minLines = minLines, maxLines = maxLines) val selectionRegistrar = LocalSelectionRegistrar.current @@ -129,7 +134,8 @@ fun BasicText( onPlaceholderLayout = null, selectionController = selectionController, color = color, - onShowTranslation = null + onShowTranslation = null, + autoSize = requireAutoSizeInternalImplementationOrNull(autoSize) ) } else { modifier.optionalGraphicsLayer() then @@ -141,7 +147,8 @@ fun BasicText( softWrap = softWrap, maxLines = maxLines, minLines = minLines, - color = color + color = color, + autoSize = requireAutoSizeInternalImplementationOrNull(autoSize) ) } Layout(finalModifier, EmptyMeasurePolicy) @@ -171,6 +178,10 @@ fun BasicText( * @param inlineContent A map store composables that replaces certain ranges of the text. It's used * to insert composables into text layout. Check [InlineTextContent] for more information. * @param color Overrides the text color provided in [style] + * @param autoSize Enable auto sizing for this text composable. Finds the biggest font size that + * fits in the available space and lays the text out with this size. This performs multiple layout + * passes and can be slower than using a fixed font size. This takes precedence over sizes defined + * through [style]. */ @Composable fun BasicText( @@ -183,7 +194,8 @@ fun BasicText( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, inlineContent: Map = mapOf(), - color: ColorProducer? = null + color: ColorProducer? = null, + autoSize: AutoSize? = null ) { validateMinMaxLines(minLines = minLines, maxLines = maxLines) val selectionRegistrar = LocalSelectionRegistrar.current @@ -221,7 +233,8 @@ fun BasicText( onPlaceholderLayout = null, selectionController = selectionController, color = color, - onShowTranslation = null + onShowTranslation = null, + autoSize = requireAutoSizeInternalImplementationOrNull(autoSize) ), EmptyMeasurePolicy ) @@ -251,11 +264,104 @@ fun BasicText( } else { substitutionValue.original } - } + }, + autoSize = requireAutoSizeInternalImplementationOrNull(autoSize) ) } } +/** + * Basic element that displays text and provides semantics / accessibility information. Typically + * you will instead want to use [androidx.compose.material.Text], which is a higher level Text + * element that contains semantics and consumes style information from a theme. + * + * @param text The text to be displayed. + * @param modifier [Modifier] to apply to this layout node. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param overflow How visual overflow should be handled. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [overflow] and TextAlign may have unexpected effects. + * @param maxLines An optional maximum number of lines for the text to span, wrapping if necessary. + * If the text exceeds the given number of lines, it will be truncated according to [overflow] and + * [softWrap]. It is required that 1 <= [minLines] <= [maxLines]. + * @param minLines The minimum height in terms of minimum number of visible lines. It is required + * that 1 <= [minLines] <= [maxLines]. + * @param color Overrides the text color provided in [style] + */ +@Deprecated("Maintained for binary compatibility", level = DeprecationLevel.HIDDEN) +@Composable +fun BasicText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + color: ColorProducer? = null +) { + BasicText(text, modifier, style, onTextLayout, overflow, softWrap, maxLines, minLines, color) +} + +/** + * Basic element that displays text and provides semantics / accessibility information. Typically + * you will instead want to use [androidx.compose.material.Text], which is a higher level Text + * element that contains semantics and consumes style information from a theme. + * + * @param text The text to be displayed. + * @param modifier [Modifier] to apply to this layout node. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param overflow How visual overflow should be handled. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [overflow] and TextAlign may have unexpected effects. + * @param maxLines An optional maximum number of lines for the text to span, wrapping if necessary. + * If the text exceeds the given number of lines, it will be truncated according to [overflow] and + * [softWrap]. It is required that 1 <= [minLines] <= [maxLines]. + * @param minLines The minimum height in terms of minimum number of visible lines. It is required + * that 1 <= [minLines] <= [maxLines]. + * @param inlineContent A map store composables that replaces certain ranges of the text. It's used + * to insert composables into text layout. Check [InlineTextContent] for more information. + * @param color Overrides the text color provided in [style] + */ +@Deprecated("Maintained for binary compatibility", level = DeprecationLevel.HIDDEN) +@Composable +fun BasicText( + text: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + color: ColorProducer? = null +) { + BasicText( + text, + modifier, + style, + onTextLayout, + overflow, + softWrap, + maxLines, + minLines, + inlineContent, + color + ) +} + @Deprecated("Maintained for binary compatibility", level = DeprecationLevel.HIDDEN) @Composable fun BasicText( @@ -298,8 +404,8 @@ fun BasicText( onTextLayout = onTextLayout, overflow = overflow, softWrap = softWrap, - minLines = 1, maxLines = maxLines, + minLines = 1, inlineContent = inlineContent ) } @@ -468,7 +574,8 @@ private fun Modifier.textModifier( onPlaceholderLayout: ((List) -> Unit)?, selectionController: SelectionController?, color: ColorProducer?, - onShowTranslation: ((TextAnnotatedStringNode.TextSubstitutionValue) -> Unit)? + onShowTranslation: ((TextAnnotatedStringNode.TextSubstitutionValue) -> Unit)?, + autoSize: TextAutoSize?, ): Modifier { if (selectionController == null) { val staticTextModifier = @@ -485,7 +592,7 @@ private fun Modifier.textModifier( onPlaceholderLayout, null, color, - null, + autoSize, onShowTranslation ) return this then Modifier /* selection position */ then staticTextModifier @@ -503,7 +610,8 @@ private fun Modifier.textModifier( placeholders, onPlaceholderLayout, selectionController, - color + color, + autoSize ) return this then selectionController.modifier then selectableTextModifier } @@ -524,7 +632,8 @@ private fun LayoutWithLinksAndInlineContent( fontFamilyResolver: FontFamily.Resolver, selectionController: SelectionController?, color: ColorProducer?, - onShowTranslation: ((TextAnnotatedStringNode.TextSubstitutionValue) -> Unit)? + onShowTranslation: ((TextAnnotatedStringNode.TextSubstitutionValue) -> Unit)?, + autoSize: TextAutoSize? ) { val textScope = @@ -581,7 +690,8 @@ private fun LayoutWithLinksAndInlineContent( onPlaceholderLayout = onPlaceholderLayout, selectionController = selectionController, color = color, - onShowTranslation = onShowTranslation + onShowTranslation = onShowTranslation, + autoSize = autoSize ), measurePolicy = if (!hasInlineContent) { @@ -608,3 +718,15 @@ private fun Modifier.optionalGraphicsLayer() = } else { this.graphicsLayer() } + +/** + * [AutoSize], our public type, is a sealed interface. Our internal representation is not sealed. + * This is an extra validity check to ensure we are receiving the correct type; in practice it + * should never happen. + */ +private fun requireAutoSizeInternalImplementationOrNull(autoSize: AutoSize?): TextAutoSize? { + if (autoSize != null && autoSize !is TextAutoSize) { + throw IllegalArgumentException("AutoSize must implement TextAutoSize") + } + return autoSize as? TextAutoSize +} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt index 598abca45c0e7..d584f4eefc430 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.handwriting.stylusHandwriting import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.OutputTransformation @@ -63,6 +64,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalAutofillManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback @@ -78,6 +80,8 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow private object BasicTextFieldDefaults { val CursorBrush = SolidColor(Color.Black) @@ -244,6 +248,9 @@ internal fun BasicTextField( val isFocused = interactionSource.collectIsFocusedAsState().value val isDragHovered = interactionSource.collectIsHoveredAsState().value val isWindowFocused = windowInfo.isWindowFocused + val stylusHandwritingTrigger = remember { + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST) + } val transformedState = remember(state, codepointTransformation, outputTransformation) { @@ -286,6 +293,7 @@ internal fun BasicTextField( val currentHapticFeedback = LocalHapticFeedback.current val currentClipboardManager = LocalClipboardManager.current val currentTextToolbar = LocalTextToolbar.current + val autofillManager = LocalAutofillManager.current SideEffect { // These properties are not backed by snapshot state, so they can't be updated directly in // composition. @@ -299,11 +307,16 @@ internal fun BasicTextField( enabled = enabled, readOnly = readOnly, isPassword = isPassword, + autofillManager = autofillManager ) } DisposableEffect(textFieldSelectionState) { onDispose { textFieldSelectionState.dispose() } } + val handwritingEnabled = + !isPassword && + keyboardOptions.keyboardType != KeyboardType.Password && + keyboardOptions.keyboardType != KeyboardType.NumberPassword val decorationModifiers = modifier .then( @@ -319,9 +332,31 @@ internal fun BasicTextField( keyboardActionHandler = onKeyboardAction, singleLine = singleLine, interactionSource = interactionSource, - isPassword = isPassword + isPassword = isPassword, + stylusHandwritingTrigger = stylusHandwritingTrigger ) ) + .stylusHandwriting(enabled, handwritingEnabled) { + // If this is a password field, we can't trigger handwriting. + // The expected behavior is 1) request focus 2) show software keyboard. + // Note: TextField will show software keyboard automatically when it + // gain focus. 3) show a toast message telling that handwriting is not + // supported for password fields. TODO(b/335294152) + if (handwritingEnabled) { + // Send the handwriting start signal to platform. + // The editor should send the signal when it is focused or is about + // to gain focus, Here are more details: + // 1) if the editor already has an active input session, the + // platform handwriting service should already listen to this flow + // and it'll start handwriting right away. + // + // 2) if the editor is not focused, but it'll be focused and + // create a new input session, one handwriting signal will be + // replayed when the platform collect this flow. And the platform + // should trigger handwriting accordingly. + stylusHandwritingTrigger.tryEmit(Unit) + } + } .focusable(interactionSource = interactionSource, enabled = enabled) .scrollable( state = scrollState, @@ -415,9 +450,10 @@ internal fun BasicTextField( @Composable internal fun TextFieldCursorHandle(selectionState: TextFieldSelectionState) { // Does not recompose if only position of the handle changes. - val cursorHandleState by remember { - derivedStateOf { selectionState.getCursorHandleState(includePosition = false) } - } + val cursorHandleState by + remember(selectionState) { + derivedStateOf { selectionState.getCursorHandleState(includePosition = false) } + } if (cursorHandleState.visible) { CursorHandle( offsetProvider = { diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt index 109e10bc4f6f7..83b7e6cd0462d 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt @@ -56,6 +56,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentDataType import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester @@ -78,6 +79,7 @@ import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofillManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -87,6 +89,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.semantics.contentDataType import androidx.compose.ui.semantics.copyText import androidx.compose.ui.semantics.cutText import androidx.compose.ui.semantics.disabled @@ -94,6 +97,7 @@ import androidx.compose.ui.semantics.editableText import androidx.compose.ui.semantics.getTextLayoutResult import androidx.compose.ui.semantics.insertTextAtCursor import androidx.compose.ui.semantics.isEditable +import androidx.compose.ui.semantics.onAutofillText import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.onImeAction import androidx.compose.ui.semantics.onLongClick @@ -303,6 +307,7 @@ internal fun CoreTextField( manager.clipboardManager = LocalClipboardManager.current manager.textToolbar = LocalTextToolbar.current manager.hapticFeedBack = LocalHapticFeedback.current + manager.autofillManager = LocalAutofillManager.current manager.focusRequester = focusRequester manager.editable = !readOnly manager.enabled = enabled @@ -454,6 +459,17 @@ internal fun CoreTextField( // focused semantics are handled by Modifier.focusable() this.editableText = transformedText.text this.textSelectionRange = value.selection + + // The developer will set `contentType`. CTF populates the other autofill-related + // semantics. And since we're in a TextField, set the `contentDataType` to be "Text". + this.contentDataType = ContentDataType.Text + onAutofillText { text -> + state.justAutofilled = true + state.autofillHighlightOn = true + handleTextUpdateFromSemantics(state, text.text, readOnly, enabled) + true + } + if (!enabled) this.disabled() if (isPassword) this.password() val editable = enabled && !readOnly @@ -468,23 +484,7 @@ internal fun CoreTextField( } if (editable) { setText { text -> - // If the action is performed while in an active text editing session, treat - // this like an IME command and update the text by going through the buffer. - // This keeps the buffer state consistent if other IME commands are performed - // before the next recomposition, and is used for the testing code path. - state.inputSession?.let { session -> - TextFieldDelegate.onEditCommand( - ops = listOf(DeleteAllCommand(), CommitTextCommand(text, 1)), - editProcessor = state.processor, - state.onValueChange, - session - ) - } - ?: run { - state.onValueChange( - TextFieldValue(text.text, TextRange(text.text.length)) - ) - } + handleTextUpdateFromSemantics(state, text.text, readOnly, enabled) true } @@ -634,20 +634,17 @@ internal fun CoreTextField( imeAction = imeOptions.imeAction, ) + val handwritingEnabled = + imeOptions.keyboardType != KeyboardType.Password && + imeOptions.keyboardType != KeyboardType.NumberPassword val stylusHandwritingModifier = - Modifier.stylusHandwriting(writeable) { - if (!state.hasFocus) { - focusRequester.requestFocus() - } + Modifier.stylusHandwriting(writeable, handwritingEnabled) { // If this is a password field, we can't trigger handwriting. // The expected behavior is 1) request focus 2) show software keyboard. // Note: TextField will show software keyboard automatically when it // gain focus. 3) show a toast message telling that handwriting is not // supported for password fields. TODO(b/335294152) - if ( - imeOptions.keyboardType != KeyboardType.Password && - imeOptions.keyboardType != KeyboardType.NumberPassword - ) { + if (handwritingEnabled) { // TextInputService is calling LegacyTextInputServiceAdapter under the // hood. And because it's a public API, startStylusHandwriting is added // to legacyTextInputServiceAdapter instead. @@ -656,7 +653,14 @@ internal fun CoreTextField( // internally by the LegacyTextInputServiceAdapter. legacyTextInputServiceAdapter.startStylusHandwriting() } - true + } + + val autofillHighlight = LocalAutofillHighlight.current + val drawDecorationModifier = + Modifier.drawBehind { + if (state.autofillHighlightOn || state.justAutofilled) { + drawRect(color = autofillHighlight.autofillHighlightColor) + } } val overscrollEffect = rememberTextFieldOverscrollEffect() @@ -665,6 +669,7 @@ internal fun CoreTextField( // gesture and semantics modifiers. val decorationBoxModifier = modifier + .then(drawDecorationModifier) .legacyTextInputAdapter(legacyTextInputServiceAdapter, state, manager) .then(stylusHandwritingModifier) .then(focusModifier) @@ -687,11 +692,6 @@ internal fun CoreTextField( CoreTextFieldRootBox(decorationBoxModifier, manager) { decorationBox { - fun Modifier.overscroll(): Modifier = - overscrollEffect?.let { - this then it.effectModifier - } ?: this - // Modifiers applied directly to the internal input field implementation. In general, // these will most likely include draw, layout and IME related modifiers. val coreTextFieldModifier = @@ -701,7 +701,6 @@ internal fun CoreTextField( // TextFields .heightIn(min = state.minHeightForSingleLineField) .heightInLines(textStyle = textStyle, minLines = minLines, maxLines = maxLines) - .overscroll() .textFieldScroll( scrollerPosition = scrollerPosition, textFieldValue = value, @@ -868,6 +867,30 @@ private fun Modifier.previewKeyEventToDeselectOnBack( } } +/** + * In an active input session, semantics updates are handled just as user updates coming from the + * IME. Otherwise the updates are directly applied on the current state. + */ +private fun handleTextUpdateFromSemantics( + state: LegacyTextFieldState, + text: String, + readOnly: Boolean, + enabled: Boolean +) { + if (readOnly || !enabled) return + + // If the action is performed while in an active text editing session, treat this + // like an IME command and update the text by going through the buffer. + state.inputSession?.let { session -> + TextFieldDelegate.onEditCommand( + ops = listOf(DeleteAllCommand(), CommitTextCommand(text, 1)), + editProcessor = state.processor, + state.onValueChange, + session + ) + } ?: run { state.onValueChange(TextFieldValue(text, TextRange(text.length))) } +} + internal class LegacyTextFieldState( var textDelegate: TextDelegate, val recomposeScope: RecomposeScope, @@ -987,6 +1010,10 @@ internal class LegacyTextFieldState( private val keyboardActionRunner: KeyboardActionRunner = KeyboardActionRunner(keyboardController) + /** Autofill related values we need to save between */ + var autofillHighlightOn by mutableStateOf(false) + var justAutofilled by mutableStateOf(false) + /** * DO NOT USE, use [onValueChange] instead. This is original callback provided to the TextField. * In order the CoreTextField to work, the recompose.invalidate() has to be called when we call @@ -998,6 +1025,13 @@ internal class LegacyTextFieldState( if (it.text != untransformedText?.text) { // Text has been changed, enter the HandleState.None and hide the cursor handle. handleState = HandleState.None + + // Autofill logic + if (justAutofilled) { + justAutofilled = false + } else { + autofillHighlightOn = false + } } selectionPreviewHighlightRange = TextRange.Zero deletionPreviewHighlightRange = TextRange.Zero diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt index 6841b897fd15f..e2d5bc9f97eb5 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.input.TextInputService import androidx.compose.ui.text.input.TextInputSession import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -82,7 +83,7 @@ internal fun computeSizeForDefaultText( style = style, spanStyles = listOf(), maxLines = maxLines, - ellipsis = false, + overflow = TextOverflow.Clip, density = density, fontFamilyResolver = fontFamilyResolver, constraints = Constraints() diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextLinkScope.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextLinkScope.kt index eed31153218a7..fc8dbc23532a3 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextLinkScope.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextLinkScope.kt @@ -173,63 +173,66 @@ internal class TextLinkScope(internal val initialText: AnnotatedString) { val links = text.getLinkAnnotations(0, text.length) links.fastForEach { range -> - val shape = shapeForRange(range) - val clipModifier = shape?.let { Modifier.clip(it) } ?: Modifier - val interactionSource = remember { MutableInteractionSource() } - - Box( - clipModifier - .semantics { - // adding this to identify links in tests, see performFirstLinkClick - this[LinkTestMarker] = Unit + if (range.start != range.end) { + val shape = shapeForRange(range) + val clipModifier = shape?.let { Modifier.clip(it) } ?: Modifier + val interactionSource = remember { MutableInteractionSource() } + + Box( + clipModifier + .semantics { + // adding this to identify links in tests, see performFirstLinkClick + this[LinkTestMarker] = Unit + } + .textRange(range.start, range.end) + .hoverable(interactionSource) + .pointerHoverIcon(PointerIcon.Hand) + .combinedClickable( + indication = null, + interactionSource = interactionSource, + onClick = { handleLink(range.item, uriHandler) } + ) + ) + + if (!range.item.styles.isNullOrEmpty()) { + // the interaction source is not hoisted, we create and remember it in the + // code above. Therefore there's no need to pass it as a key to the remember and + // a + // launch effect. + val linkStateObserver = remember { + LinkStateInteractionSourceObserver(interactionSource) + } + LaunchedEffect(Unit) { linkStateObserver.collectInteractionsForLinks() } + + StyleAnnotation( + linkStateObserver.isHovered, + linkStateObserver.isFocused, + linkStateObserver.isPressed, + range.item.styles?.style, + range.item.styles?.focusedStyle, + range.item.styles?.hoveredStyle, + range.item.styles?.pressedStyle, + ) { + // we calculate the latest style based on the link state and apply it to the + // initialText's style. This allows us to merge the style with the original + // instead of fully replacing it + val mergedStyle = + range.item.styles + ?.style + .mergeOrUse( + if (linkStateObserver.isFocused) range.item.styles?.focusedStyle + else null + ) + .mergeOrUse( + if (linkStateObserver.isHovered) range.item.styles?.hoveredStyle + else null + ) + .mergeOrUse( + if (linkStateObserver.isPressed) range.item.styles?.pressedStyle + else null + ) + replaceStyle(range, mergedStyle) } - .textRange(range.start, range.end) - .hoverable(interactionSource) - .pointerHoverIcon(PointerIcon.Hand) - .combinedClickable( - indication = null, - interactionSource = interactionSource, - onClick = { handleLink(range.item, uriHandler) } - ) - ) - - if (!range.item.styles.isNullOrEmpty()) { - // the interaction source is not hoisted, we create and remember it in the - // code above. Therefore there's no need to pass it as a key to the remember and a - // launch effect. - val linkStateObserver = remember { - LinkStateInteractionSourceObserver(interactionSource) - } - LaunchedEffect(Unit) { linkStateObserver.collectInteractionsForLinks() } - - StyleAnnotation( - linkStateObserver.isHovered, - linkStateObserver.isFocused, - linkStateObserver.isPressed, - range.item.styles?.style, - range.item.styles?.focusedStyle, - range.item.styles?.hoveredStyle, - range.item.styles?.pressedStyle, - ) { - // we calculate the latest style based on the link state and apply it to the - // initialText's style. This allows us to merge the style with the original - // instead of fully replacing it - val mergedStyle = - range.item.styles - ?.style - .mergeOrUse( - if (linkStateObserver.isFocused) range.item.styles?.focusedStyle - else null - ) - .mergeOrUse( - if (linkStateObserver.isHovered) range.item.styles?.hoveredStyle - else null - ) - .mergeOrUse( - if (linkStateObserver.isPressed) range.item.styles?.pressedStyle - else null - ) - replaceStyle(range, mergedStyle) } } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.kt index 3034f6be0d2d4..f730100a9fbe2 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.kt @@ -18,4 +18,9 @@ package androidx.compose.foundation.text import androidx.compose.ui.input.pointer.PointerIcon +// TODO Replace to PointerIcon.Text. No need for expect/actual internal expect val textPointerIcon: PointerIcon + +// TODO Add appropriate property to PointerIcon interface instead of bypassing abstraction layer. +// https://youtrack.jetbrains.com/issue/CMP-7145/Properly-adopt-stylus-handwriting-hover-icon +internal expect val handwritingPointerIcon: PointerIcon diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt index dac0923e23742..f02e6e5d840ab 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt @@ -19,27 +19,27 @@ package androidx.compose.foundation.text.handwriting import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.isDeepPress -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.handwritingPointerIcon import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusEventModifierNode +import androidx.compose.ui.focus.FocusRequesterModifierNode import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.requestFocus import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.input.pointer.stylusHoverIcon import androidx.compose.ui.node.DelegatingNode -import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.DpTouchBoundsExpansion import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.TouchBoundsExpansion +import androidx.compose.ui.node.requireDensity import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.offset import androidx.compose.ui.util.fastFirstOrNull /** @@ -51,31 +51,31 @@ import androidx.compose.ui.util.fastFirstOrNull * @param enabled whether this modifier is enabled, it's used for the case where the editor is * readOnly or disabled. * @param onHandwritingSlopExceeded the callback that's invoked when it detects stylus handwriting. - * The return value determines whether the handwriting is triggered or not. When it's true, this - * modifier will consume the pointer events. + * And this modifier will consume the pointer events. */ internal fun Modifier.stylusHandwriting( enabled: Boolean, - onHandwritingSlopExceeded: () -> Boolean + showHoverIcon: Boolean, + onHandwritingSlopExceeded: () -> Unit ): Modifier = if (enabled && isStylusHandwritingSupported) { - this.then(StylusHandwritingElementWithNegativePadding(onHandwritingSlopExceeded)) - .padding( - horizontal = HandwritingBoundsHorizontalOffset, - vertical = HandwritingBoundsVerticalOffset - ) + if (showHoverIcon) { + this.stylusHoverIcon(handwritingPointerIcon, false, HandwritingBoundsExpansion) + } else { + this + } + .then(StylusHandwritingElement(onHandwritingSlopExceeded)) } else { this } -private data class StylusHandwritingElementWithNegativePadding( - val onHandwritingSlopExceeded: () -> Boolean -) : ModifierNodeElement() { - override fun create(): StylusHandwritingNodeWithNegativePadding { - return StylusHandwritingNodeWithNegativePadding(onHandwritingSlopExceeded) +private data class StylusHandwritingElement(val onHandwritingSlopExceeded: () -> Unit) : + ModifierNodeElement() { + override fun create(): StylusHandwritingNode { + return StylusHandwritingNode(onHandwritingSlopExceeded) } - override fun update(node: StylusHandwritingNodeWithNegativePadding) { + override fun update(node: StylusHandwritingNode) { node.onHandwritingSlopExceeded = onHandwritingSlopExceeded } @@ -85,36 +85,8 @@ private data class StylusHandwritingElementWithNegativePadding( } } -/** - * A stylus handwriting node with negative padding. This node should be used in pair with a padding - * modifier. Together, they expands the touch bounds of the editor while keep its visual bounds the - * same. Note: this node is a temporary solution, ideally we don't need it. - */ -internal class StylusHandwritingNodeWithNegativePadding(onHandwritingSlopExceeded: () -> Boolean) : - StylusHandwritingNode(onHandwritingSlopExceeded), LayoutModifierNode { - override fun MeasureScope.measure( - measurable: Measurable, - constraints: Constraints - ): MeasureResult { - val paddingVerticalPx = HandwritingBoundsVerticalOffset.roundToPx() - val paddingHorizontalPx = HandwritingBoundsHorizontalOffset.roundToPx() - val newConstraint = constraints.offset(2 * paddingHorizontalPx, 2 * paddingVerticalPx) - val placeable = measurable.measure(newConstraint) - - val height = placeable.height - paddingVerticalPx * 2 - val width = placeable.width - paddingHorizontalPx * 2 - return layout(width, height) { placeable.place(-paddingHorizontalPx, -paddingVerticalPx) } - } - - override fun sharePointerInputWithSiblings(): Boolean { - // Share events to siblings so that the expanded touch bounds won't block other elements - // surrounding the editor. - return true - } -} - -internal open class StylusHandwritingNode(var onHandwritingSlopExceeded: () -> Boolean) : - DelegatingNode(), PointerInputModifierNode, FocusEventModifierNode { +internal open class StylusHandwritingNode(var onHandwritingSlopExceeded: () -> Unit) : + DelegatingNode(), PointerInputModifierNode, FocusEventModifierNode, FocusRequesterModifierNode { private var focused = false @@ -122,6 +94,9 @@ internal open class StylusHandwritingNode(var onHandwritingSlopExceeded: () -> B focused = focusState.isFocused } + override val touchBoundsExpansion: TouchBoundsExpansion + get() = HandwritingBoundsExpansion.roundToTouchBoundsExpansion(requireDensity()) + private val suspendingPointerInputModifierNode = delegate( SuspendingPointerInputModifierNode { @@ -180,9 +155,15 @@ internal open class StylusHandwritingNode(var onHandwritingSlopExceeded: () -> B } } - if (exceedsTouchSlop == null || !onHandwritingSlopExceeded.invoke()) { + if (exceedsTouchSlop == null) { return@awaitEachGesture } + + if (!focused) { + requestFocus() + } + + onHandwritingSlopExceeded.invoke() exceedsTouchSlop.consume() // Consume the remaining changes of this pointer. @@ -222,5 +203,12 @@ internal open class StylusHandwritingNode(var onHandwritingSlopExceeded: () -> B internal expect val isStylusHandwritingSupported: Boolean /** The amount of the padding added to the handwriting bounds of an editor. */ -internal val HandwritingBoundsVerticalOffset = 0.dp -internal val HandwritingBoundsHorizontalOffset = 0.dp +internal val HandwritingBoundsVerticalOffset = 40.dp +internal val HandwritingBoundsHorizontalOffset = 10.dp +internal val HandwritingBoundsExpansion = + DpTouchBoundsExpansion( + start = HandwritingBoundsHorizontalOffset, + top = HandwritingBoundsVerticalOffset, + end = HandwritingBoundsHorizontalOffset, + bottom = HandwritingBoundsVerticalOffset + ) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldBuffer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldBuffer.kt index 112defc793275..7b368cf280c41 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldBuffer.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldBuffer.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.collection.MutableVector import androidx.compose.runtime.collection.mutableVectorOf import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.coerceIn import androidx.compose.ui.util.fastForEach import kotlin.jvm.JvmName @@ -310,7 +311,7 @@ internal constructor( buffer.replace(start, end, text, textStart, textEnd) commitComposition() - highlight = null + clearHighlight() } /** @@ -446,9 +447,16 @@ internal constructor( */ internal fun toTextFieldCharSequence( selection: TextRange = this.selection, - composition: TextRange? = this.composition + composition: TextRange? = this.composition, + composingAnnotations: List? = + this.composingAnnotations?.asMutableList()?.takeIf { it.isNotEmpty() }, ): TextFieldCharSequence = - TextFieldCharSequence(buffer.toString(), selection = selection, composition = composition) + TextFieldCharSequence( + text = buffer.toString(), + selection = selection, + composition = composition, + composingAnnotations = composingAnnotations + ) private fun requireValidIndex(index: Int, startExclusive: Boolean, endExclusive: Boolean) { val start = if (startExclusive) 0 else -1 @@ -700,3 +708,13 @@ internal inline fun findCommonPrefixAndSuffix( onFound(aStart, aEnd, bStart, bEnd) } + +/** + * Normally [TextFieldBuffer] throws an [IllegalArgumentException] when an invalid selection change + * is attempted. However internally and especially for selection ranges coming from the IME we + * coerce the given numbers to a valid range to not crash. Also, IMEs sometimes send values like + * `Int.MAX_VALUE` to move selection to end. + */ +internal fun TextFieldBuffer.setSelectionCoerced(start: Int, end: Int = start) { + selection = TextRange(start.coerceIn(0, length), end.coerceIn(0, length)) +} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt index 286462912a1da..122d4e1881cf3 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt @@ -335,8 +335,17 @@ internal constructor( return } - // There's a meaningful change to the buffer, let's run the full logic. - // first take a _snapshot_ of current state of the mainBuffer after changes are applied. + // Eventually we may need to run a string equality check between old value and the new value + // This is an O(n) operation, meaning that it gets as expensive as the text length. + // Therefore we create this flag to remind ourselves whether the original changes may have + // caused a content difference. This value being false is a strong indicator that the + // content definitely hasn't changed. However this being true only introduces a possibility. + // The content change may have been an exact string replacement like "ab" => "ab". + val contentMayHaveChanged = mainBuffer.changeTracker.changeCount != 0 + + // There's a meaningful change to the buffer, either content or selection. We need to run + // the full logic including InputTransformation. But F=first take a _snapshot_ of current + // state of the mainBuffer after changes are applied. val afterEditValue = TextFieldCharSequence( text = mainBuffer.toString(), @@ -355,7 +364,11 @@ internal constructor( updateValueAndNotifyListeners( oldValue = beforeEditValue, newValue = afterEditValue, - restartImeIfContentChanges = restartImeIfContentChanges + // updateValueAndNotifyListeners use restartImeIfContentChanges flag to possibly + // skip doing string equality check. Here we add our own flag to indicate the + // possibility of content changing. Since false value is a string indicator, + // this added logic works. + restartImeIfContentChanges = contentMayHaveChanged && restartImeIfContentChanges ) recordEditForUndo( previousValue = beforeEditValue, @@ -431,7 +444,19 @@ internal constructor( value = newValue finishEditing() - notifyImeListeners.forEach { it.onChange(oldValue, newValue, restartImeIfContentChanges) } + notifyImeListeners.forEach { + it.onChange( + oldValue = oldValue, + newValue = newValue, + restartIme = + restartImeIfContentChanges && + !oldValue.contentEquals(newValue) + // No need to restart the IME if there wasn't a composing region. This is + // useful to not unnecessarily restart digit only, or password fields. + && + oldValue.composition != null + ) + } } /** @@ -481,23 +506,31 @@ internal constructor( * * State in [TextFieldState] can change through various means but categorically there are two * sources; Developer([TextFieldState.edit]) and User([TextFieldState.editAsUser]). Only - * non-InputTransformed IME sourced changes can skip updating the IME. Otherwise, all changes + * non-InputTransformed, IME sourced changes can skip updating the IME. Otherwise, all changes * must be sent to the IME to let it synchronize its state with the [TextFieldState]. Such a - * communication channel is established by the IME registering a [NotifyImeListener] on a - * [TextFieldState]. + * communication channel is established by the text input session registering a + * [NotifyImeListener] on a [TextFieldState]. */ internal fun interface NotifyImeListener { /** - * Called when the value in [TextFieldState] changes via any source. The - * [restartImeIfContentChanges] flag determines whether a text change between [oldValue] and - * [newValue] should restart the ongoing input connection. Selection changes never require a - * restart. + * Called when the value in [TextFieldState] changes via any source. The [restartIme] flag + * determines whether the ongoing input connection should be restarted. Selection or + * Composition range changes never require a restart. + * + * @param oldValue The previous value of the [TextFieldState] before the latest changes are + * applied with one exception. If an [InputTransformation] is applied on the changes + * coming from the IME, we use the value after user changes are applied but before + * [InputTransformation]. This is essentially the last known state to the IME. + * @param newValue Current state of the [TextFieldState]. This is always equal to the + * [TextFieldState.value] at the time of calling this function. + * @param restartIme Whether to ignore other parameters and basically restart the input + * session with new configuration. */ fun onChange( oldValue: TextFieldCharSequence, newValue: TextFieldCharSequence, - restartImeIfContentChanges: Boolean + restartIme: Boolean ) } @@ -538,7 +571,11 @@ internal constructor( // Composition should be decided by the IME after the content or selection has been // changed programmatically, outside the knowledge of the IME. - mainBuffer.commitComposition() + if ( + textChanged || selectionChanged || oldValue.composition != temporaryBuffer.composition + ) { + mainBuffer.commitComposition() + } val finalValue = mainBuffer.toTextFieldCharSequence() diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt index 319e13f0d56f5..af7ade22b8cd1 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt @@ -29,18 +29,23 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.Handle import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.handwriting.StylusHandwritingNode -import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported +import androidx.compose.foundation.text.LocalAutofillHighlight import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState import androidx.compose.foundation.text.input.internal.selection.TextToolbarState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.autofill.ContentDataType import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusEventModifierNode import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequesterModifierNode import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.requestFocus +import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyInputModifierNode import androidx.compose.ui.input.pointer.PointerEvent @@ -50,6 +55,7 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.modifier.ModifierLocalModifierNode import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.GlobalPositionAwareModifierNode import androidx.compose.ui.node.LayoutAwareModifierNode import androidx.compose.ui.node.ModifierNodeElement @@ -71,6 +77,7 @@ import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.platform.establishTextInputSession import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.contentDataType import androidx.compose.ui.semantics.copyText import androidx.compose.ui.semantics.cutText import androidx.compose.ui.semantics.disabled @@ -78,6 +85,7 @@ import androidx.compose.ui.semantics.editableText import androidx.compose.ui.semantics.getTextLayoutResult import androidx.compose.ui.semantics.insertTextAtCursor import androidx.compose.ui.semantics.isEditable +import androidx.compose.ui.semantics.onAutofillText import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.onImeAction import androidx.compose.ui.semantics.onLongClick @@ -90,14 +98,14 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeOptions -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.IntSize import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) private val MediaTypesText = setOf(MediaType.Text) @@ -122,7 +130,8 @@ internal data class TextFieldDecoratorModifier( private val keyboardActionHandler: KeyboardActionHandler?, private val singleLine: Boolean, private val interactionSource: MutableInteractionSource, - private val isPassword: Boolean + private val isPassword: Boolean, + private val stylusHandwritingTrigger: MutableSharedFlow? ) : ModifierNodeElement() { override fun create(): TextFieldDecoratorModifierNode = TextFieldDecoratorModifierNode( @@ -136,7 +145,8 @@ internal data class TextFieldDecoratorModifier( keyboardActionHandler = keyboardActionHandler, singleLine = singleLine, interactionSource = interactionSource, - isPassword = isPassword + isPassword = isPassword, + stylusHandwritingTrigger = stylusHandwritingTrigger ) override fun update(node: TextFieldDecoratorModifierNode) { @@ -151,7 +161,8 @@ internal data class TextFieldDecoratorModifier( keyboardActionHandler = keyboardActionHandler, singleLine = singleLine, interactionSource = interactionSource, - isPassword = isPassword + isPassword = isPassword, + stylusHandwritingTrigger = stylusHandwritingTrigger ) } @@ -173,9 +184,11 @@ internal class TextFieldDecoratorModifierNode( var keyboardActionHandler: KeyboardActionHandler?, var singleLine: Boolean, var interactionSource: MutableInteractionSource, - var isPassword: Boolean + var isPassword: Boolean, + var stylusHandwritingTrigger: MutableSharedFlow? ) : DelegatingNode(), + DrawModifierNode, PlatformTextInputModifierNode, SemanticsModifierNode, FocusRequesterModifierNode, @@ -191,19 +204,6 @@ internal class TextFieldDecoratorModifierNode( private val editable get() = enabled && !readOnly - private var backingStylusHandwritingTrigger: MutableSharedFlow? = null - private val stylusHandwritingTrigger: MutableSharedFlow? - get() { - val finalStylusHandwritingTrigger = backingStylusHandwritingTrigger - if (finalStylusHandwritingTrigger != null) return finalStylusHandwritingTrigger - if (!isStylusHandwritingSupported) return null - return MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_LATEST - ) - .also { backingStylusHandwritingTrigger = it } - } - private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { @@ -234,39 +234,6 @@ internal class TextFieldDecoratorModifierNode( } ) - private val stylusHandwritingNode = - delegate( - StylusHandwritingNode { - if (!isFocused) { - requestFocus() - } - - // If this is a password field, we can't trigger handwriting. - // The expected behavior is 1) request focus 2) show software keyboard. - // Note: TextField will show software keyboard automatically when it - // gain focus. 3) show a toast message telling that handwriting is not - // supported for password fields. TODO(b/335294152) - if ( - keyboardOptions.keyboardType != KeyboardType.Password && - keyboardOptions.keyboardType != KeyboardType.NumberPassword - ) { - // Send the handwriting start signal to platform. - // The editor should send the signal when it is focused or is about - // to gain focus, Here are more details: - // 1) if the editor already has an active input session, the - // platform handwriting service should already listen to this flow - // and it'll start handwriting right away. - // - // 2) if the editor is not focused, but it'll be focused and - // create a new input session, one handwriting signal will be - // replayed when the platform collect this flow. And the platform - // should trigger handwriting accordingly. - stylusHandwritingTrigger?.tryEmit(Unit) - } - return@StylusHandwritingNode true - } - ) - /** * The last enter event that was submitted to [interactionSource] from [dragAndDropNode]. We * need to keep a reference to this event to send a follow-up exit event. @@ -410,6 +377,26 @@ internal class TextFieldDecoratorModifierNode( getReceiveContentConfiguration() } + // Mutable state to hold the autofillHighlightOn value + private var autofillHighlightOn by mutableStateOf(false) + + override fun ContentDrawScope.draw() { + drawContent() + + // Autofill highlight is drawn on top of the content — this way the coloring appears over + // any Material background applied. + if (autofillHighlightOn) { + drawRect(color = currentValueOf(LocalAutofillHighlight).autofillHighlightColor) + } + } + + private suspend fun observeUntransformedTextChanges() { + snapshotFlow { textFieldState.untransformedText.toString() } + .drop(1) + .take(1) + .collect { autofillHighlightOn = false } + } + /** Updates all the related properties and invalidates internal state based on the changes. */ fun updateNode( textFieldState: TransformedTextFieldState, @@ -422,7 +409,8 @@ internal class TextFieldDecoratorModifierNode( keyboardActionHandler: KeyboardActionHandler?, singleLine: Boolean, interactionSource: MutableInteractionSource, - isPassword: Boolean + isPassword: Boolean, + stylusHandwritingTrigger: MutableSharedFlow? ) { // Find the diff: current previous and new values before updating current. val previousEditable = this.editable @@ -434,6 +422,7 @@ internal class TextFieldDecoratorModifierNode( val previousTextFieldSelectionState = this.textFieldSelectionState val previousInteractionSource = this.interactionSource val previousIsPassword = this.isPassword + val previousStylusHandwritingTrigger = this.stylusHandwritingTrigger // Apply the diff. this.textFieldState = textFieldState @@ -447,13 +436,15 @@ internal class TextFieldDecoratorModifierNode( this.singleLine = singleLine this.interactionSource = interactionSource this.isPassword = isPassword + this.stylusHandwritingTrigger = stylusHandwritingTrigger // React to diff. // Something about the session changed, restart the session. if ( editable != previousEditable || textFieldState != previousTextFieldState || - keyboardOptions != previousKeyboardOptions + keyboardOptions != previousKeyboardOptions || + stylusHandwritingTrigger != previousStylusHandwritingTrigger ) { if (editable && isFocused) { // The old session will be implicitly disposed. @@ -475,7 +466,6 @@ internal class TextFieldDecoratorModifierNode( if (textFieldSelectionState != previousTextFieldSelectionState) { pointerInputNode.resetPointerInputHandler() - stylusHandwritingNode.resetPointerInputHandler() if (isAttached) { textFieldSelectionState.receiveContentConfiguration = receiveContentConfigurationProvider @@ -484,7 +474,6 @@ internal class TextFieldDecoratorModifierNode( if (interactionSource != previousInteractionSource) { pointerInputNode.resetPointerInputHandler() - stylusHandwritingNode.resetPointerInputHandler() } } @@ -503,6 +492,18 @@ internal class TextFieldDecoratorModifierNode( isEditable = this@TextFieldDecoratorModifierNode.editable + // The developer will set `contentType`. TF populates the other autofill-related + // semantics. And since we're in a TextField, set the `contentDataType` to be "Text". + this.contentDataType = ContentDataType.Text + + onAutofillText { newText -> + if (!editable) return@onAutofillText false + textFieldState.replaceAll(newText) + autofillHighlightOn = true + coroutineScope.launch { observeUntransformedTextChanges() } + true + } + getTextLayoutResult { textLayoutState.layoutResult?.let { result -> it.add(result) } ?: false } @@ -621,10 +622,9 @@ internal class TextFieldDecoratorModifierNode( disposeInputSession() // only clear the composing region when element loses focus. Window focus lost should // not clear the composing region. - textFieldState.editUntransformedTextAsUser { finishComposingText() } + textFieldState.editUntransformedTextAsUser { commitComposition() } textFieldState.collapseSelectionToMax() } - stylusHandwritingNode.onFocusEvent(focusState) } /** @@ -661,12 +661,10 @@ internal class TextFieldDecoratorModifierNode( pass: PointerEventPass, bounds: IntSize ) { - stylusHandwritingNode.onPointerEvent(pointerEvent, pass, bounds) pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) } override fun onCancelPointerInput() { - stylusHandwritingNode.onCancelPointerInput() pointerInputNode.onCancelPointerInput() } @@ -727,6 +725,11 @@ internal class TextFieldDecoratorModifierNode( imeOptions = keyboardOptions.toImeOptions(singleLine), receiveContentConfiguration = receiveContentConfiguration, onImeAction = ::onImeActionPerformed, + updateSelectionState = { + textFieldSelectionState.updateTextToolbarState( + TextToolbarState.Selection + ) + }, stylusHandwritingTrigger = stylusHandwritingTrigger, viewConfiguration = currentValueOf(LocalViewConfiguration) ) @@ -775,6 +778,7 @@ internal expect suspend fun PlatformTextInputSession.platformSpecificTextInputSe imeOptions: ImeOptions, receiveContentConfiguration: ReceiveContentConfiguration?, onImeAction: ((ImeAction) -> Unit)?, + updateSelectionState: (() -> Unit)? = null, stylusHandwritingTrigger: MutableSharedFlow? = null, viewConfiguration: ViewConfiguration? = null ): Nothing diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldKeyEventHandler.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldKeyEventHandler.kt index f33bf62541b4d..95a405ccf82dd 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldKeyEventHandler.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldKeyEventHandler.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.text.KeyCommand import androidx.compose.foundation.text.appendCodePointX import androidx.compose.foundation.text.cancelsTextSelection import androidx.compose.foundation.text.input.internal.selection.TextFieldPreparedSelection -import androidx.compose.foundation.text.input.internal.selection.TextFieldPreparedSelection.Companion.NoCharacterFound import androidx.compose.foundation.text.input.internal.selection.TextFieldPreparedSelectionState import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState import androidx.compose.foundation.text.isTypedEvent @@ -34,7 +33,6 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.compose.ui.text.TextRange /** Factory function to create a platform specific [TextFieldKeyEventHandler]. */ internal expect fun createTextFieldKeyEventHandler(): TextFieldKeyEventHandler @@ -139,12 +137,11 @@ internal abstract class TextFieldKeyEventHandler { if (codePoint != null) { val text = StringBuilder(2).appendCodePointX(codePoint).toString() return if (editable) { - textFieldState.editUntransformedTextAsUser( + textFieldState.replaceSelectedText( + newText = text, + clearComposition = true, restartImeIfContentChanges = !event.isFromSoftKeyboard - ) { - commitComposition() - commitText(text, 1) - } + ) preparedSelectionState.resetCachedX() true } else { @@ -163,8 +160,8 @@ internal abstract class TextFieldKeyEventHandler { KeyCommand.COPY -> textFieldSelectionState.copy(false) KeyCommand.PASTE -> textFieldSelectionState.paste() KeyCommand.CUT -> textFieldSelectionState.cut() - KeyCommand.LEFT_CHAR -> collapseLeftOr { moveCursorLeft() } - KeyCommand.RIGHT_CHAR -> collapseRightOr { moveCursorRight() } + KeyCommand.LEFT_CHAR -> collapseLeftOr { moveCursorLeftByChar() } + KeyCommand.RIGHT_CHAR -> collapseRightOr { moveCursorRightByChar() } KeyCommand.LEFT_WORD -> moveCursorLeftByWord() KeyCommand.RIGHT_WORD -> moveCursorRightByWord() KeyCommand.PREV_PARAGRAPH -> moveCursorPrevByParagraph() @@ -179,57 +176,37 @@ internal abstract class TextFieldKeyEventHandler { KeyCommand.LINE_RIGHT -> moveCursorToLineRightSide() KeyCommand.HOME -> moveCursorToHome() KeyCommand.END -> moveCursorToEnd() - KeyCommand.DELETE_PREV_CHAR -> { - deleteIfSelectedOr { - getPrecedingCharacterIndex() - .takeIf { it != NoCharacterFound } - ?.let { TextRange(it, selection.end) } - } - } - KeyCommand.DELETE_NEXT_CHAR -> { - // Note that some software keyboards, such as Samsung, go through this code - // path instead of making calls on the InputConnection directly. - deleteIfSelectedOr { - getNextCharacterIndex() - .takeIf { it != NoCharacterFound } - ?.let { TextRange(selection.start, it) } - } - } - KeyCommand.DELETE_PREV_WORD -> { - deleteIfSelectedOr { TextRange(getPreviousWordOffset(), selection.end) } - } - KeyCommand.DELETE_NEXT_WORD -> { - deleteIfSelectedOr { TextRange(selection.start, getNextWordOffset()) } - } - KeyCommand.DELETE_FROM_LINE_START -> { - deleteIfSelectedOr { TextRange(getLineStartByOffset(), selection.end) } - } - KeyCommand.DELETE_TO_LINE_END -> { - deleteIfSelectedOr { TextRange(selection.start, getLineEndByOffset()) } - } + KeyCommand.DELETE_PREV_CHAR -> moveCursorPrevByChar().deleteMovement() + KeyCommand.DELETE_NEXT_CHAR -> moveCursorNextByChar().deleteMovement() + KeyCommand.DELETE_PREV_WORD -> moveCursorPrevByWord().deleteMovement() + KeyCommand.DELETE_NEXT_WORD -> moveCursorNextByWord().deleteMovement() + KeyCommand.DELETE_FROM_LINE_START -> moveCursorToLineStart().deleteMovement() + KeyCommand.DELETE_TO_LINE_END -> moveCursorToLineEnd().deleteMovement() KeyCommand.NEW_LINE -> { if (!singleLine) { - textFieldState.editUntransformedTextAsUser { - commitComposition() - commitText("\n", 1) - } + textFieldState.replaceSelectedText( + newText = "\n", + clearComposition = true, + restartImeIfContentChanges = !event.isFromSoftKeyboard + ) } else { onSubmit() } } KeyCommand.TAB -> { if (!singleLine) { - textFieldState.editUntransformedTextAsUser { - commitComposition() - commitText("\t", 1) - } + textFieldState.replaceSelectedText( + newText = "\t", + clearComposition = true, + restartImeIfContentChanges = !event.isFromSoftKeyboard + ) } else { consumed = false // let propagate to focus system } } KeyCommand.SELECT_ALL -> selectAll() - KeyCommand.SELECT_LEFT_CHAR -> moveCursorLeft().selectMovement() - KeyCommand.SELECT_RIGHT_CHAR -> moveCursorRight().selectMovement() + KeyCommand.SELECT_LEFT_CHAR -> moveCursorLeftByChar().selectMovement() + KeyCommand.SELECT_RIGHT_CHAR -> moveCursorRightByChar().selectMovement() KeyCommand.SELECT_LEFT_WORD -> moveCursorLeftByWord().selectMovement() KeyCommand.SELECT_RIGHT_WORD -> moveCursorRightByWord().selectMovement() KeyCommand.SELECT_PREV_PARAGRAPH -> moveCursorPrevByParagraph().selectMovement() @@ -280,6 +257,17 @@ internal abstract class TextFieldKeyEventHandler { // selection changes are applied atomically at the end of context evaluation state.selectCharsIn(preparedSelection.selection) } + + if (preparedSelection.wedgeAffinity != null) { + preparedSelection.wedgeAffinity?.let { wedgeAffinity -> + if (state.untransformedText.selection.collapsed) { + state.selectionWedgeAffinity = SelectionWedgeAffinity(wedgeAffinity) + } else { + state.selectionWedgeAffinity = + preparedSelection.initialWedgeAffinity.copy(endAffinity = wedgeAffinity) + } + } + } } /** diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt index f60a95c7440e9..c26e0c17fa5bc 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.text.input.internal.IndexTransformationType.I import androidx.compose.foundation.text.input.internal.IndexTransformationType.Replacement import androidx.compose.foundation.text.input.internal.IndexTransformationType.Untransformed import androidx.compose.foundation.text.input.internal.undo.TextFieldEditUndoBehavior +import androidx.compose.foundation.text.input.setSelectionCoerced import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf @@ -196,7 +197,7 @@ internal class TransformedTextFieldState( fun selectUntransformedCharsIn(untransformedRange: TextRange) { textFieldState.editAsUser(inputTransformation) { - setSelection(untransformedRange.start, untransformedRange.end) + setSelectionCoerced(untransformedRange.start, untransformedRange.end) } } @@ -207,15 +208,17 @@ internal class TransformedTextFieldState( } } + /** Replaces the entire content of the [textFieldState] with [newText]. */ fun replaceAll(newText: CharSequence) { textFieldState.editAsUser(inputTransformation) { - deleteAll() - commitText(newText.toString(), 1) + delete(0, length) + append(newText.toString()) + updateWedgeAffinity() } } fun selectAll() { - textFieldState.editAsUser(inputTransformation) { setSelection(0, length) } + textFieldState.editAsUser(inputTransformation) { setSelectionCoerced(0, length) } } fun deleteSelectedText() { @@ -225,7 +228,8 @@ internal class TransformedTextFieldState( ) { // `selection` is read from the buffer, so we don't need to transform it. delete(selection.min, selection.max) - setSelection(selection.min, selection.min) + setSelectionCoerced(selection.min) + updateWedgeAffinity() } } @@ -247,16 +251,22 @@ internal class TransformedTextFieldState( val selection = mapFromTransformed(range) replace(selection.min, selection.max, newText) val cursor = selection.min + newText.length - setSelection(cursor, cursor) + setSelectionCoerced(cursor) + updateWedgeAffinity() } } fun replaceSelectedText( newText: CharSequence, clearComposition: Boolean = false, - undoBehavior: TextFieldEditUndoBehavior = TextFieldEditUndoBehavior.MergeIfPossible + undoBehavior: TextFieldEditUndoBehavior = TextFieldEditUndoBehavior.MergeIfPossible, + restartImeIfContentChanges: Boolean = true ) { - textFieldState.editAsUser(inputTransformation, undoBehavior = undoBehavior) { + textFieldState.editAsUser( + inputTransformation = inputTransformation, + restartImeIfContentChanges = restartImeIfContentChanges, + undoBehavior = undoBehavior + ) { if (clearComposition) { commitComposition() } @@ -265,21 +275,22 @@ internal class TransformedTextFieldState( val selection = selection replace(selection.min, selection.max, newText) val cursor = selection.min + newText.length - setSelection(cursor, cursor) + setSelectionCoerced(cursor) + updateWedgeAffinity() } } fun collapseSelectionToMax() { textFieldState.editAsUser(inputTransformation) { // `selection` is read from the buffer, so we don't need to transform it. - setSelection(selection.max, selection.max) + setSelectionCoerced(selection.max) } } fun collapseSelectionToEnd() { textFieldState.editAsUser(inputTransformation) { // `selection` is read from the buffer, so we don't need to transform it. - setSelection(selection.end, selection.end) + setSelectionCoerced(selection.end) } } @@ -296,7 +307,8 @@ internal class TransformedTextFieldState( * will be fed into the [outputTransformation]. Any operations performed on this buffer MUST * take care to explicitly convert between transformed and untransformed offsets and ranges. * When possible, use the other methods on this class to manipulate selection to avoid having to - * do these conversions manually. + * do these conversions manually. Additionally any edit that ends up collapsing the selection + * resets the [selectionWedgeAffinity] back to [WedgeAffinity.Start]. * * @see mapToTransformed * @see mapFromTransformed @@ -307,9 +319,21 @@ internal class TransformedTextFieldState( ) { textFieldState.editAsUser( inputTransformation = inputTransformation, - restartImeIfContentChanges = restartImeIfContentChanges, - block = block - ) + restartImeIfContentChanges = restartImeIfContentChanges + ) { + block() + updateWedgeAffinity() + } + } + + /** + * If the text content changes after text is edited and the selection is collapsed into a + * cursor, wedge affinity needs to be updated. + */ + private fun TextFieldBuffer.updateWedgeAffinity() { + if (changeTracker.changeCount > 0 && this@updateWedgeAffinity.selection.collapsed) { + selectionWedgeAffinity = SelectionWedgeAffinity(WedgeAffinity.Start) + } } /** @@ -396,16 +420,44 @@ internal class TransformedTextFieldState( // TODO(b/296583846) Get rid of this. /** - * Adds [notifyImeListener] to the underlying [TextFieldState] and then suspends until - * cancelled, removing the listener before continuing. + * Adds a [TextFieldState.NotifyImeListener] to the underlying [TextFieldState] and then + * suspends until cancelled, removing the listener before continuing. + * + * This listener is responsible for updating the IME about the latest changes to the underlying + * [TextFieldState]. Please note that the IME should be aware of the [outputText], rather than + * [untransformedText] since users mainly interact with the output representation. + * + * The real challenge comes from the fact that IME doesn't need updates if its commands are not + * interfered with. That's why [TextFieldState.NotifyImeListener] actually sends the latest + * synced value from IME, rather than the previous value inside the [TextFieldState] before the + * changes are applied. In the existence of [OutputTransformation], we have to transform these + * values once more before updating the IME. */ suspend fun collectImeNotifications( notifyImeListener: TextFieldState.NotifyImeListener ): Nothing { + val transformedNotifyImeListener = + if (outputTransformation != null) { + TextFieldState.NotifyImeListener { oldValue, _, restartIme -> + notifyImeListener.onChange( + oldValue = + calculateTransformedText( + untransformedValue = oldValue, + outputTransformation = outputTransformation, + wedgeAffinity = selectionWedgeAffinity + ) + ?.text ?: oldValue, + newValue = visualText, + restartIme = restartIme + ) + } + } else { + notifyImeListener + } suspendCancellableCoroutine { continuation -> - textFieldState.addNotifyImeListener(notifyImeListener) + textFieldState.addNotifyImeListener(transformedNotifyImeListener) continuation.invokeOnCancellation { - textFieldState.removeNotifyImeListener(notifyImeListener) + textFieldState.removeNotifyImeListener(transformedNotifyImeListener) } } } @@ -476,20 +528,19 @@ internal class TransformedTextFieldState( val transformedTextWithSelection = buffer.toTextFieldCharSequence( // Pass the calculator explicitly since the one on transformedText won't be - // updated - // yet. + // updated yet. selection = mapToTransformed( range = untransformedValue.selection, mapping = offsetMappingCalculator, - wedgeAffinity = wedgeAffinity + selectionWedgeAffinity = wedgeAffinity ), composition = untransformedValue.composition?.let { mapToTransformed( range = it, mapping = offsetMappingCalculator, - wedgeAffinity = wedgeAffinity + selectionWedgeAffinity = wedgeAffinity ) } ) @@ -545,23 +596,50 @@ internal class TransformedTextFieldState( /** * Maps [range] from untransformed to transformed indices. * - * @param wedgeAffinity The [SelectionWedgeAffinity] to use to collapse the transformed - * range if necessary. If null, the range will be returned uncollapsed. + * @param selectionWedgeAffinity The [SelectionWedgeAffinity] to use to collapse the + * transformed range if necessary. If null, the range will be returned uncollapsed. */ @kotlin.jvm.JvmStatic private fun mapToTransformed( range: TextRange, mapping: OffsetMappingCalculator, - wedgeAffinity: SelectionWedgeAffinity? = null + selectionWedgeAffinity: SelectionWedgeAffinity? = null ): TextRange { - val transformedStart = mapping.mapFromSource(range.start) + var transformedStart = mapping.mapFromSource(range.start) // Avoid calculating mapping again if it's going to be the same value. - val transformedEnd = + var transformedEnd = if (range.collapsed) transformedStart else { mapping.mapFromSource(range.end) } + // Do not use separate affinities when the selection is collapsed into a cursor. + // This can show a selected region around a wedge when there is no selection in + // the untransformed space. We use startAffinity for cursors. + val startAffinity = selectionWedgeAffinity?.startAffinity + val endAffinity = + if (range.collapsed) { + startAffinity + } else { + selectionWedgeAffinity?.endAffinity + } + + if (startAffinity != null && !transformedStart.collapsed) { + transformedStart = + when (startAffinity) { + WedgeAffinity.Start -> TextRange(transformedStart.start) + WedgeAffinity.End -> TextRange(transformedStart.end) + } + } + + if (endAffinity != null && !transformedEnd.collapsed) { + transformedEnd = + when (endAffinity) { + WedgeAffinity.Start -> TextRange(transformedEnd.start) + WedgeAffinity.End -> TextRange(transformedEnd.end) + } + } + val transformedMin = minOf(transformedStart.min, transformedEnd.min) val transformedMax = maxOf(transformedStart.max, transformedEnd.max) val transformedRange = @@ -571,16 +649,7 @@ internal class TransformedTextFieldState( TextRange(transformedMin, transformedMax) } - return if (range.collapsed && !transformedRange.collapsed) { - // In a wedge. - when (wedgeAffinity?.startAffinity) { - WedgeAffinity.Start -> TextRange(transformedRange.start) - WedgeAffinity.End -> TextRange(transformedRange.end) - null -> transformedRange - } - } else { - transformedRange - } + return transformedRange } @kotlin.jvm.JvmStatic @@ -607,7 +676,11 @@ internal class TransformedTextFieldState( } } -/** Represents the [WedgeAffinity] for both sides of a selection. */ +/** + * Represents the [WedgeAffinity] for both sides of a selection. + * + * If the selection is collapsed into a cursor, only [startAffinity] is used. + */ internal data class SelectionWedgeAffinity( val startAffinity: WedgeAffinity, val endAffinity: WedgeAffinity, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt index 262a02e6b4d4d..b46b2e7669a44 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.text.DefaultCursorThickness import androidx.compose.foundation.text.Handle import androidx.compose.foundation.text.TextDragObserver +import androidx.compose.foundation.text.getLineHeight import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.getSelectedText import androidx.compose.foundation.text.input.internal.IndexTransformationType.Deletion @@ -62,6 +63,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.isSpecified @@ -111,6 +113,9 @@ internal class TextFieldSelectionState( var isFocused: Boolean, private var isPassword: Boolean, ) { + /** [AutofillManager] to perform Autofill. */ + private var autofillManager: AutofillManager? = null + /** [HapticFeedback] handle to perform haptic feedback. */ private var hapticFeedBack: HapticFeedback? = null @@ -264,6 +269,10 @@ internal class TextFieldSelectionState( if (!visible) return TextFieldHandleState.Hidden + // The line height field for the cursor handle state is currently unused. + // There is no need to calculate it. + val lineHeight = 0f + // text direction is useless for cursor handle, any value is fine. return TextFieldHandleState( visible = true, @@ -343,6 +352,7 @@ internal class TextFieldSelectionState( enabled: Boolean, readOnly: Boolean, isPassword: Boolean, + autofillManager: AutofillManager? ) { if (!enabled) { hideTextToolbar() @@ -354,6 +364,7 @@ internal class TextFieldSelectionState( this.enabled = enabled this.readOnly = readOnly this.isPassword = isPassword + this.autofillManager = autofillManager } /** Implements the complete set of gestures supported by the cursor handle. */ @@ -431,6 +442,7 @@ internal class TextFieldSelectionState( textToolbar = null clipboardManager = null hapticFeedBack = null + autofillManager = null } /** @@ -1193,6 +1205,7 @@ internal class TextFieldSelectionState( } else { Offset.Unspecified } + val handleOffset = if (isStartHandle) selection.start else selection.end return TextFieldHandleState( visible = true, position = coercedPosition, @@ -1388,6 +1401,21 @@ internal class TextFieldSelectionState( textFieldState.selectAll() } + /** + * Whether autofill can execute upon this text field. The autofill action only appears when the + * text field is editable and no text is currently selected. + */ + fun canAutofill(): Boolean = editable && textFieldState.visualText.selection.collapsed + + /** + * The method for autofilling. + * + * Inserts credentials (if there exist any that match this field type) into the text field. + */ + fun autofill() { + autofillManager?.requestAutofillForActiveElement() + } + /** * This function get the selected region as a Rectangle region, and pass it to [TextToolbar] to * make the FloatingToolbar show up in the proper place. In addition, this function passes the @@ -1403,6 +1431,7 @@ internal class TextFieldSelectionState( onPasteRequested = menuItem(canPaste(), None) { paste() }, onCutRequested = menuItem(canCut(), None) { cut() }, onSelectAllRequested = menuItem(canSelectAll(), Selection) { selectAll() }, + onAutofillRequested = menuItem(canAutofill(), None) { autofill() } ) } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextPreparedSelection.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextPreparedSelection.kt index d3f63196091d7..8812ffd42cf85 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextPreparedSelection.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextPreparedSelection.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.text.findFollowingBreak import androidx.compose.foundation.text.findParagraphEnd import androidx.compose.foundation.text.findParagraphStart import androidx.compose.foundation.text.findPrecedingBreak +import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.internal.IndexTransformationType.Deletion import androidx.compose.foundation.text.input.internal.IndexTransformationType.Insertion import androidx.compose.foundation.text.input.internal.IndexTransformationType.Replacement @@ -35,6 +36,10 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.util.packInts +import androidx.compose.ui.util.unpackInt1 +import androidx.compose.ui.util.unpackInt2 +import kotlin.jvm.JvmInline import kotlin.math.abs /** @@ -65,8 +70,8 @@ internal class TextFieldPreparedSelectionState { * For many of these operations, it's particularly important to keep the difference between * selection start and selection end. In some systems, they are called "anchor" and "caret" * respectively. For example, for selection from scratch, after [moveCursorLeftByWord] - * [moveCursorRight] will move the left side of the selection, but after [moveCursorRightByWord] the - * right one. + * [moveCursorRightByChar] will move the left side of the selection, but after + * [moveCursorRightByWord] the right one. * * @param state Transformed version of TextFieldState that helps to manipulate underlying buffer * through transformed coordinates. @@ -93,68 +98,24 @@ internal class TextFieldPreparedSelection( * [TextFieldPreparedSelection]. It is also used to make comparison between the initial state * and the modified state of selection and content. */ - val initialValue = Snapshot.withoutReadObservation { state.visualText } + val initialValue: TextFieldCharSequence - /** Current active selection in the context of this [TextFieldPreparedSelection] */ - var selection = initialValue.selection - - /** Initial text value. */ - private val text: String = initialValue.toString() + val initialWedgeAffinity: SelectionWedgeAffinity - /** - * Deletes selected region from [state] if [selection] is not collapsed. Otherwise, deletes the - * range returned by [block]. If returned TextRange is null, this function does nothing. - */ - inline fun deleteIfSelectedOr(block: () -> TextRange?) { - if (!selection.collapsed) { - state.replaceText( - newText = "", - range = selection, - restartImeIfContentChanges = !isFromSoftKeyboard - ) - } else { - block()?.let { - state.replaceText( - newText = "", - range = it, - restartImeIfContentChanges = !isFromSoftKeyboard - ) - } + init { + Snapshot.withoutReadObservation { + initialValue = state.visualText + initialWedgeAffinity = state.selectionWedgeAffinity } } - /** Executes PageUp key */ - fun moveCursorUpByPage() = applyIfNotEmpty(false) { setCursor(jumpByPagesOffset(-1)) } + /** Current active selection in the context of this [TextFieldPreparedSelection] */ + var selection = initialValue.selection - /** Executes PageDown key */ - fun moveCursorDownByPage() = applyIfNotEmpty(false) { setCursor(jumpByPagesOffset(1)) } + var wedgeAffinity: WedgeAffinity? = null - /** - * Returns a cursor position after jumping back or forth by [pagesAmount] number of pages, where - * `page` is the visible amount of space in the text field. Visible rectangle is calculated by - * the coordinates of decoration box around the TextField. If text layout has not been measured - * yet, this function returns the current offset. - */ - private fun jumpByPagesOffset(pagesAmount: Int): Int { - val currentOffset = initialValue.selection.end - if (textLayoutResult == null || visibleTextLayoutHeight.isNaN()) return currentOffset - val currentPos = textLayoutResult.getCursorRect(currentOffset) - val newPos = - currentPos.translate( - translateX = 0f, - translateY = visibleTextLayoutHeight * pagesAmount - ) - // which line does the new cursor position belong? - val topLine = textLayoutResult.getLineForVerticalPosition(newPos.top) - val lineSeparator = textLayoutResult.getLineBottom(topLine) - return if (abs(newPos.top - lineSeparator) > abs(newPos.bottom - lineSeparator)) { - // most of new cursor is on top line - textLayoutResult.getOffsetForPosition(newPos.topLeft) - } else { - // most of new cursor is on bottom line - textLayoutResult.getOffsetForPosition(newPos.bottomLeft) - } - } + /** Initial text value. */ + private val text: String = initialValue.text.toString() /** * Only apply the given [block] if the text is not empty. @@ -175,30 +136,23 @@ internal class TextFieldPreparedSelection( return this } - /** Sets a collapsed selection at given [offset]. */ - private fun setCursor(offset: Int) { - selection = TextRange(offset, offset) - } - fun selectAll() = applyIfNotEmpty { selection = TextRange(0, text.length) } - fun deselect() = applyIfNotEmpty { setCursor(selection.end) } + fun deselect() = applyIfNotEmpty { selection = TextRange(selection.end) } - fun moveCursorLeft() = applyIfNotEmpty { + fun moveCursorLeftByChar() = if (isLtr()) { - moveCursorPrev() + moveCursorPrevByChar() } else { - moveCursorNext() + moveCursorNextByChar() } - } - fun moveCursorRight() = applyIfNotEmpty { + fun moveCursorRightByChar() = if (isLtr()) { - moveCursorNext() + moveCursorNextByChar() } else { - moveCursorPrev() + moveCursorPrevByChar() } - } /** If there is already a selection, collapse it to the left side. Otherwise, execute [or] */ fun collapseLeftOr(or: TextFieldPreparedSelection.() -> Unit) = applyIfNotEmpty { @@ -206,9 +160,9 @@ internal class TextFieldPreparedSelection( or(this) } else { if (isLtr()) { - setCursor(selection.min) + selection = TextRange(selection.min) } else { - setCursor(selection.max) + selection = TextRange(selection.max) } } } @@ -219,9 +173,9 @@ internal class TextFieldPreparedSelection( or(this) } else { if (isLtr()) { - setCursor(selection.max) + selection = TextRange(selection.max) } else { - setCursor(selection.min) + selection = TextRange(selection.min) } } } @@ -235,106 +189,134 @@ internal class TextFieldPreparedSelection( */ fun getNextCharacterIndex() = text.findFollowingBreak(selection.end) - private fun moveCursorPrev() = applyIfNotEmpty { - val oldCursor = selection.end - val newCursor = calculateAdjacentCursorPosition(text, oldCursor, forward = false, state) - if (newCursor != oldCursor) { - setCursor(newCursor) - } - } + /** + * Moves the current cursor to the index provided by the [proposedCursorMovement] while + * respecting the existing transformations on the text. + */ + private inline fun moveCursorTo( + resetCachedX: Boolean = true, + proposedCursorMovement: () -> Int + ) = + applyIfNotEmpty(resetCachedX) { + val oldCursor = selection.end + val (newCursor, newWedgeAffinity) = + calculateNextCursorPositionAndWedgeAffinity( + proposedCursor = proposedCursorMovement(), + cursor = oldCursor, + transformedTextFieldState = state + ) - private fun moveCursorNext() = applyIfNotEmpty { - val oldCursor = selection.end - val newCursor = calculateAdjacentCursorPosition(text, oldCursor, forward = true, state) - if (newCursor != oldCursor) { - setCursor(newCursor) + if (newCursor != oldCursor || !selection.collapsed) { + selection = TextRange(newCursor) + } + if (newWedgeAffinity != null) { + wedgeAffinity = newWedgeAffinity + } } - } - fun moveCursorToHome() = applyIfNotEmpty { setCursor(0) } + fun moveCursorPrevByChar() = moveCursorTo { text.findPrecedingBreak(selection.end) } + + fun moveCursorNextByChar() = moveCursorTo { text.findFollowingBreak(selection.end) } - fun moveCursorToEnd() = applyIfNotEmpty { setCursor(text.length) } + fun moveCursorToHome() = moveCursorTo { 0 } - fun moveCursorLeftByWord() = applyIfNotEmpty { + fun moveCursorToEnd() = moveCursorTo { text.length } + + fun moveCursorLeftByWord() = if (isLtr()) { moveCursorPrevByWord() } else { moveCursorNextByWord() } - } - fun moveCursorRightByWord() = applyIfNotEmpty { + fun moveCursorRightByWord() = if (isLtr()) { moveCursorNextByWord() } else { moveCursorPrevByWord() } - } - - fun getNextWordOffset(): Int = textLayoutResult?.getNextWordOffsetForLayout() ?: text.length - private fun moveCursorNextByWord() = applyIfNotEmpty { setCursor(getNextWordOffset()) } - - fun getPreviousWordOffset(): Int = textLayoutResult?.getPrevWordOffsetForLayout() ?: 0 + fun moveCursorNextByWord() = moveCursorTo { + textLayoutResult?.getNextWordOffsetForLayout() ?: text.length + } - private fun moveCursorPrevByWord() = applyIfNotEmpty { setCursor(getPreviousWordOffset()) } + fun moveCursorPrevByWord() = moveCursorTo { + textLayoutResult?.getPrevWordOffsetForLayout() ?: 0 + } - fun moveCursorPrevByParagraph() = applyIfNotEmpty { + fun moveCursorPrevByParagraph() = moveCursorTo { var paragraphStart = text.findParagraphStart(selection.min) if (paragraphStart == selection.min && paragraphStart != 0) { paragraphStart = text.findParagraphStart(paragraphStart - 1) } - setCursor(paragraphStart) + paragraphStart } - fun moveCursorNextByParagraph() = applyIfNotEmpty { + fun moveCursorNextByParagraph() = moveCursorTo { var paragraphEnd = text.findParagraphEnd(selection.max) if (paragraphEnd == selection.max && paragraphEnd != text.length) { paragraphEnd = text.findParagraphEnd(paragraphEnd + 1) } - setCursor(paragraphEnd) + paragraphEnd } - fun moveCursorUpByLine(): TextFieldPreparedSelection { - textLayoutResult ?: return this - return applyIfNotEmpty(false) { setCursor(textLayoutResult!!.jumpByLinesOffset(-1)) } - } - - fun moveCursorDownByLine(): TextFieldPreparedSelection { - textLayoutResult ?: return this - return applyIfNotEmpty(false) { setCursor(textLayoutResult!!.jumpByLinesOffset(1)) } - } - - fun getLineStartByOffset(): Int = textLayoutResult?.getLineStartByOffsetForLayout() ?: 0 + fun moveCursorUpByLine() = + moveCursorTo(resetCachedX = false) { textLayoutResult?.jumpByLinesOffset(-1) ?: 0 } - fun moveCursorToLineStart() = applyIfNotEmpty { setCursor(getLineStartByOffset()) } + fun moveCursorDownByLine() = + moveCursorTo(resetCachedX = false) { textLayoutResult?.jumpByLinesOffset(1) ?: text.length } - fun getLineEndByOffset(): Int = textLayoutResult?.getLineEndByOffsetForLayout() ?: text.length - - fun moveCursorToLineEnd() = applyIfNotEmpty { setCursor(getLineEndByOffset()) } - - fun moveCursorToLineLeftSide() = applyIfNotEmpty { + fun moveCursorToLineLeftSide() = if (isLtr()) { moveCursorToLineStart() } else { moveCursorToLineEnd() } - } - fun moveCursorToLineRightSide() = applyIfNotEmpty { + fun moveCursorToLineRightSide() = if (isLtr()) { moveCursorToLineEnd() } else { moveCursorToLineStart() } + + fun moveCursorToLineStart() = moveCursorTo { + textLayoutResult?.getLineStartByOffsetForLayout() ?: 0 } + fun moveCursorToLineEnd() = moveCursorTo { + textLayoutResult?.getLineEndByOffsetForLayout() ?: text.length + } + + /** Executes PageUp key */ + fun moveCursorUpByPage() = moveCursorTo(false) { jumpByPagesOffset(-1) } + + /** Executes PageDown key */ + fun moveCursorDownByPage() = moveCursorTo(false) { jumpByPagesOffset(1) } + /** Selects a text from the original selection start to a current selection end. */ fun selectMovement() = applyIfNotEmpty(resetCachedX = false) { selection = TextRange(initialValue.selection.start, selection.end) } + fun deleteMovement() = + applyIfNotEmpty(resetCachedX = false) { + if (!initialValue.selection.collapsed) { + state.deleteSelectedText() + } else { + state.replaceText( + newText = "", + range = TextRange(initialValue.selection.start, selection.end), + restartImeIfContentChanges = !isFromSoftKeyboard + ) + } + // Update the internal selection to where it was moved by the delete operation. + selection = state.visualText.selection + // any wedgeAffinity set by the cursor movement is irrelevant after deletion + wedgeAffinity = WedgeAffinity.Start + } + private fun isLtr(): Boolean { val direction = textLayoutResult?.getParagraphDirection(selection.end) ?: return true return direction == ResolvedTextDirection.Ltr @@ -413,6 +395,35 @@ internal class TextFieldPreparedSelection( return getOffsetForPosition(Offset(x, y)) } + /** + * Returns a cursor position after jumping back or forth by [pagesAmount] number of pages, where + * `page` is the visible amount of space in the text field. Visible rectangle is calculated by + * the bounding box of text layout coordinates inside the core coordinates. Please refer to + * `TextLayoutState` to learn more about these coordinates. + * + * If text layout has not been measured yet, this function returns the current offset. + */ + private fun jumpByPagesOffset(pagesAmount: Int): Int { + val currentOffset = initialValue.selection.end + if (textLayoutResult == null || visibleTextLayoutHeight.isNaN()) return currentOffset + val currentPos = textLayoutResult.getCursorRect(currentOffset) + val newPos = + currentPos.translate( + translateX = 0f, + translateY = visibleTextLayoutHeight * pagesAmount + ) + // which line does the new cursor position belong? + val topLine = textLayoutResult.getLineForVerticalPosition(newPos.top) + val lineSeparator = textLayoutResult.getLineBottom(topLine) + return if (abs(newPos.top - lineSeparator) > abs(newPos.bottom - lineSeparator)) { + // most of new cursor is on top line + textLayoutResult.getOffsetForPosition(newPos.topLeft) + } else { + // most of new cursor is on bottom line + textLayoutResult.getOffsetForPosition(newPos.bottomLeft) + } + } + private fun charOffset(offset: Int) = offset.coerceAtMost(text.length - 1) companion object { @@ -427,79 +438,122 @@ internal class TextFieldPreparedSelection( } /** - * Given some transformed text and the current cursor offset in that text, calculates the offset of - * the nearest next position of the cursor in the transformed text. Takes into account text + * Given the proposed next cursor offset and the current cursor offset in a TextField, calculates + * the offset of the nearest position of the cursor in the transformed text. Takes into account text * transformations ([TransformedTextFieldState]) to avoid putting the cursor in the middle of * replacements. + * + * @return The next cursor position that respects the existing transformations on the + * [transformedTextFieldState], and the new [WedgeAffinity] of the moving cursor. */ @VisibleForTesting -internal fun calculateAdjacentCursorPosition( - transformedText: String, +internal fun calculateNextCursorPositionAndWedgeAffinity( + proposedCursor: Int, cursor: Int, - forward: Boolean, - state: TransformedTextFieldState, -): Int { - // First step: find the index of the next cursor position in the visual text. In most cases this - // will be the final result, however if transformations are applied we may need to jump the - // cursor forward or backward. - val proposedCursor = - if (forward) { - transformedText.findFollowingBreak(cursor) - } else { - transformedText.findPrecedingBreak(cursor) - } + transformedTextFieldState: TransformedTextFieldState +): CursorAndWedgeAffinity { if (proposedCursor == NoCharacterFound) { // At the start or end of the text, no change. - return cursor + return CursorAndWedgeAffinity(cursor) } - // Second step: if a transformation is applied, determine if the proposed cursor position would + val forward = proposedCursor > cursor + + // if a transformation is applied, determine if the proposed cursor position would // be in a range where the cursor is not allowed to be. If so, push it to the appropriate edge // of that range. - return state.getIndexTransformationType(proposedCursor) { type, _, retransformed -> - when (type) { - Untransformed -> proposedCursor - - // It doesn't matter which end of the deleted range we put the cursor, they'll both map - // to the same transformed offset. - Deletion -> proposedCursor - - // Moving forward into a replacement means we should jump to the end, moving backwards - // into it means jump to the start. - Replacement -> if (forward) retransformed.end else retransformed.start - - // Moving into an insertion is like a replacement in that the cursor may only be placed - // on either edge of the range. However, since both edges of the range map to the same - // untransformed index, we need to set the affinity. - Insertion -> { - if (forward) { - if (proposedCursor == retransformed.start) { - // Moving to start of wedge, update affinity and set cursor. - state.selectionWedgeAffinity = SelectionWedgeAffinity(WedgeAffinity.Start) - return proposedCursor + return transformedTextFieldState.getIndexTransformationType( + transformedQueryIndex = proposedCursor, + onResult = { type, _, retransformed -> + when (type) { + // Depending on the direction we are moving we might want to adjust the existing + // wedge affinity so that touching an insertion or a replacement bound doesn't + // immediately skip that wedge. + Untransformed -> + CursorAndWedgeAffinity( + proposedCursor, + if (forward) WedgeAffinity.Start else WedgeAffinity.End + ) + + // It doesn't matter which end of the deleted range we put the cursor, they'll both + // map to the same transformed offset. + Deletion -> CursorAndWedgeAffinity(proposedCursor) + + // Moving forward into a replacement means we should jump to the end, moving + // backwards into it means jump to the start. But also we need to update the wedge + // affinity so a single jump around the replacement also doesn't force us to jump + // an insertion at the other end. + Replacement -> + if (forward) { + CursorAndWedgeAffinity(retransformed.end, WedgeAffinity.Start) } else { - // Moving to middle or end of wedge, update affinity but don't need to move - // cursor. - state.selectionWedgeAffinity = SelectionWedgeAffinity(WedgeAffinity.End) - // No offset change. - cursor + CursorAndWedgeAffinity(retransformed.start, WedgeAffinity.End) } - } else { - // We're navigating to or within a wedge. Use affinity (doesn't matter which - // one, selection is a cursor). - if (proposedCursor == retransformed.end) { - // Moving to end of wedge, update affinity and set cursor. - state.selectionWedgeAffinity = SelectionWedgeAffinity(WedgeAffinity.End) - return proposedCursor + + // Moving into an insertion is like a replacement in that the cursor may only be + // placed on either edge of the range. However, since both edges of the range map + // to the same untransformed index, we need to set the affinity. + Insertion -> { + if (forward) { + if (proposedCursor == retransformed.start) { + // Moving to start of wedge, update affinity and set cursor. + CursorAndWedgeAffinity(proposedCursor, WedgeAffinity.Start) + } else { + // Moving to middle or end of wedge, update affinity but don't need to + // move cursor. No offset change. + CursorAndWedgeAffinity(retransformed.end, WedgeAffinity.End) + } } else { - // Moving to middle or start of wedge, update affinity but don't need to - // move cursor. - state.selectionWedgeAffinity = SelectionWedgeAffinity(WedgeAffinity.Start) - // No offset change. - return cursor + // We're navigating to or within a wedge. Use affinity (doesn't matter which + // one, selection is a cursor). + if (proposedCursor == retransformed.end) { + // Moving to end of wedge, update affinity and set cursor. + CursorAndWedgeAffinity(proposedCursor, WedgeAffinity.End) + } else { + // Moving to middle or start of wedge, update affinity but don't need to + // move cursor. No offset change. + CursorAndWedgeAffinity(retransformed.start, WedgeAffinity.Start) + } } } } } - } + ) +} + +@JvmInline +internal value class CursorAndWedgeAffinity(private val value: Long) { + + constructor(cursor: Int) : this(packInts(cursor, -1)) + + constructor( + cursor: Int, + wedgeAffinity: WedgeAffinity? + ) : this( + packInts( + cursor, + when (wedgeAffinity) { + WedgeAffinity.Start -> 0 + WedgeAffinity.End -> 1 + null -> -1 + } + ) + ) + + val cursor: Int + get() = unpackInt1(value) + + val wedgeAffinity: WedgeAffinity? + get() = + unpackInt2(value).let { + when { + it < 0 -> null + it == 0 -> WedgeAffinity.Start + else -> WedgeAffinity.End + } + } + + operator fun component1() = cursor + + operator fun component2() = wedgeAffinity } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoOperation.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoOperation.kt index 27b990b328207..686473af7eaae 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoOperation.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoOperation.kt @@ -17,7 +17,7 @@ package androidx.compose.foundation.text.input.internal.undo import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.internal.setSelection +import androidx.compose.foundation.text.input.setSelectionCoerced import androidx.compose.foundation.text.timeNowMillis import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope @@ -113,7 +113,7 @@ internal class TextUndoOperation( internal fun TextFieldState.undo(op: TextUndoOperation) { editWithNoSideEffects { replace(op.index, op.index + op.postText.length, op.preText) - setSelection(op.preSelection.start, op.preSelection.end) + setSelectionCoerced(op.preSelection.start, op.preSelection.end) } } @@ -121,7 +121,7 @@ internal fun TextFieldState.undo(op: TextUndoOperation) { internal fun TextFieldState.redo(op: TextUndoOperation) { editWithNoSideEffects { replace(op.index, op.index + op.preText.length, op.postText) - setSelection(op.postSelection.start, op.postSelection.end) + setSelectionCoerced(op.postSelection.start, op.postSelection.end) } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTextLayoutScope.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTextLayoutScope.kt new file mode 100644 index 0000000000000..b36ba40589e18 --- /dev/null +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/AutoSizeTextLayoutScope.kt @@ -0,0 +1,41 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text.modifiers + +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit + +/** + * This interface is used by classes responsible for laying out text. Layout will be performed here + * alongside logic that checks if the text overflows. + * + * These methods are used by [TextAutoSize] in the [TextAutoSize.getFontSize] method, where + * developers can lay out text with different font sizes and do certain logic depending on whether + * or not the text overflows. + * + * This may be implemented in unit tests when testing [TextAutoSize.getFontSize] to see if the + * method works as intended. + */ +internal interface AutoSizeTextLayoutScope : Density { + /** + * Lay out the text with the given font size. + * + * @return true if the text overflows. + */ + fun performLayoutAndGetOverflow(fontSize: TextUnit): Boolean +} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCache.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCache.kt index b031b80b7ab7a..9db4f70573d07 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCache.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/MultiParagraphLayoutCache.kt @@ -16,9 +16,8 @@ package androidx.compose.foundation.text.modifiers -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines -import androidx.compose.foundation.text.FontSizeSearchScope +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.ceilToIntPx import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.MultiParagraph @@ -56,7 +55,7 @@ internal class MultiParagraphLayoutCache( private var maxLines: Int = Int.MAX_VALUE, private var minLines: Int = DefaultMinLines, private var placeholders: List>? = null, - private var autoSize: AutoSize? = null + private var autoSize: TextAutoSize? = null ) { /** Convert min max lines into actual constraints */ private var mMinLinesConstrainer: MinLinesConstrainer? = null @@ -183,7 +182,7 @@ internal class MultiParagraphLayoutCache( return localMin.coerceMinLines(inConstraints = constraints, minLines = minLines) } - private fun AutoSize.performAutoSize( + private fun TextAutoSize.performAutoSize( finalConstraints: Constraints, layoutDirection: LayoutDirection ): TextUnit { @@ -252,7 +251,7 @@ internal class MultiParagraphLayoutCache( maxLines: Int, minLines: Int, placeholders: List>?, - autoSize: AutoSize? + autoSize: TextAutoSize? ) { this.text = text this.style = style @@ -378,8 +377,8 @@ internal class MultiParagraphLayoutCache( return setLayoutDirection(layoutDirection).minIntrinsicWidth.ceilToIntPx() } - /** [MultiParagraph] specific implementation of [FontSizeSearchScope] */ - private inner class FontSizeSearchScopeImpl : FontSizeSearchScope { + /** [MultiParagraph] specific implementation of [AutoSizeTextLayoutScope] */ + private inner class FontSizeSearchScopeImpl : AutoSizeTextLayoutScope { /** Constraints that will be used to layout the text */ var constraints: Constraints = Constraints.fixed(0, 0) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCache.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCache.kt index d9e9f9482331d..72b6df691a64a 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCache.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/ParagraphLayoutCache.kt @@ -16,9 +16,8 @@ package androidx.compose.foundation.text.modifiers -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines -import androidx.compose.foundation.text.FontSizeSearchScope +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.ceilToIntPx import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.MultiParagraph @@ -56,7 +55,7 @@ internal class ParagraphLayoutCache( private var softWrap: Boolean = true, private var maxLines: Int = Int.MAX_VALUE, private var minLines: Int = DefaultMinLines, - private var autoSize: AutoSize? = null + private var autoSize: TextAutoSize? = null ) { /** @@ -121,7 +120,10 @@ internal class ParagraphLayoutCache( /** Backing property for [fontSizeSearchScope] */ private var _fontSizeSearchScope: FontSizeSearchScopeImpl? = null - /** Used to get the font size if AutoSize is enabled and perform layout with many font sizes */ + /** + * Used to get the font size if InternalAutoSize is enabled and perform layout with many font + * sizes + */ private val fontSizeSearchScope: FontSizeSearchScopeImpl get() { if (_fontSizeSearchScope == null) _fontSizeSearchScope = FontSizeSearchScopeImpl() @@ -161,7 +163,8 @@ internal class ParagraphLayoutCache( } if (autoSize != null) { - val optimalFontSize = autoSize!!.performAutoSize(finalConstraints, layoutDirection) + val optimalFontSize = + autoSize!!.performInternalAutoSize(finalConstraints, layoutDirection) if (optimalFontSize == style.fontSize && paragraph != null) return true style = style.copy(fontSize = optimalFontSize) // paragraphIntrinsics now does not match with style and needs to be set to null @@ -206,7 +209,7 @@ internal class ParagraphLayoutCache( * * @return The derived optimal font size */ - private fun AutoSize.performAutoSize( + private fun TextAutoSize.performInternalAutoSize( finalConstraints: Constraints, layoutDirection: LayoutDirection ): TextUnit { @@ -248,7 +251,7 @@ internal class ParagraphLayoutCache( softWrap: Boolean, maxLines: Int, minLines: Int, - autoSize: AutoSize? + autoSize: TextAutoSize? ) { this.text = text this.style = style @@ -278,8 +281,10 @@ internal class ParagraphLayoutCache( ParagraphIntrinsics( text = text, style = resolveDefaults(style, layoutDirection), + annotations = listOf(), density = density!!, - fontFamilyResolver = fontFamilyResolver + fontFamilyResolver = fontFamilyResolver, + placeholders = listOf() ) } else { localIntrinsics @@ -417,12 +422,12 @@ internal class ParagraphLayoutCache( "lastDensity=$lastDensity)" /** - * [Paragraph] specific implementation of [FontSizeSearchScope]. + * [Paragraph] specific implementation of [AutoSizeTextLayoutScope]. * * Uses [layoutText] in [ParagraphLayoutCache] to lay out the text and check for overflow. Also * caches to [style], [paragraph], [prevConstraints] and [layoutSize] */ - private inner class FontSizeSearchScopeImpl : FontSizeSearchScope { + private inner class FontSizeSearchScopeImpl : AutoSizeTextLayoutScope { /** Constraints that will be used to layout the text */ var constraints: Constraints = Constraints.fixed(0, 0) @@ -464,8 +469,10 @@ internal class ParagraphLayoutCache( ParagraphIntrinsics( text = text, style = usedStyle, + annotations = listOf(), density = this@ParagraphLayoutCache.density!!, - fontFamilyResolver = fontFamilyResolver + fontFamilyResolver = fontFamilyResolver, + placeholders = listOf() ) val localParagraph = @@ -497,7 +504,7 @@ internal class ParagraphLayoutCache( override fun TextUnit.toPx(): Float { if (isEm) { check(!originalFontSize.isEm) { - "AutoSize -> toPx(): Cannot convert Em to Px when style.fontSize is Em\n" + + "InternalAutoSize -> toPx(): Cannot convert Em to Px when style.fontSize is Em\n" + "Declare the composable's style.fontSize with Sp units instead." } if (originalFontSize == TextUnit.Unspecified) { diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringElement.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringElement.kt index 6ec54670c8f51..0a9fafa7ca865 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringElement.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringElement.kt @@ -16,8 +16,8 @@ package androidx.compose.foundation.text.modifiers -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.node.ModifierNodeElement @@ -43,7 +43,7 @@ internal data class SelectableTextAnnotatedStringElement( private val onPlaceholderLayout: ((List) -> Unit)? = null, private val selectionController: SelectionController? = null, private val color: ColorProducer? = null, - private val autoSize: AutoSize? = null + private val autoSize: TextAutoSize? = null ) : ModifierNodeElement() { override fun create(): SelectableTextAnnotatedStringNode = diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt index 1dbe8d6c38636..a0a778f50af42 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/SelectableTextAnnotatedStringNode.kt @@ -17,8 +17,8 @@ package androidx.compose.foundation.text.modifiers import androidx.compose.foundation.internal.requirePreconditionNotNull -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.drawscope.ContentDrawScope @@ -59,7 +59,7 @@ internal class SelectableTextAnnotatedStringNode( onPlaceholderLayout: ((List) -> Unit)? = null, private var selectionController: SelectionController? = null, overrideColor: ColorProducer? = null, - autoSize: AutoSize? = null, + autoSize: TextAutoSize? = null, private var onShowTranslation: ((TextAnnotatedStringNode.TextSubstitutionValue) -> Unit)? = null ) : DelegatingNode(), LayoutModifierNode, DrawModifierNode, GlobalPositionAwareModifierNode { @@ -133,7 +133,7 @@ internal class SelectableTextAnnotatedStringNode( onPlaceholderLayout: ((List) -> Unit)?, selectionController: SelectionController?, color: ColorProducer?, - autoSize: AutoSize? + autoSize: TextAutoSize? ) { delegate.doInvalidations( drawChanged = delegate.updateDraw(color, style), diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringElement.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringElement.kt index 8db153af03978..56ff2dcab9528 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringElement.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringElement.kt @@ -16,8 +16,8 @@ package androidx.compose.foundation.text.modifiers -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.node.ModifierNodeElement @@ -47,7 +47,7 @@ internal class TextAnnotatedStringElement( private val onPlaceholderLayout: ((List) -> Unit)? = null, private val selectionController: SelectionController? = null, private val color: ColorProducer? = null, - private val autoSize: AutoSize? = null, + private val autoSize: TextAutoSize? = null, private val onShowTranslation: ((TextAnnotatedStringNode.TextSubstitutionValue) -> Unit)? = null ) : ModifierNodeElement() { diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt index e51d9a37e6eb5..9079e362a0fca 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextAnnotatedStringNode.kt @@ -16,8 +16,8 @@ package androidx.compose.foundation.text.modifiers -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -79,7 +79,7 @@ internal class TextAnnotatedStringNode( private var onPlaceholderLayout: ((List) -> Unit)? = null, private var selectionController: SelectionController? = null, private var overrideColor: ColorProducer? = null, - private var autoSize: AutoSize? = null, + private var autoSize: TextAutoSize? = null, private var onShowTranslation: ((TextSubstitutionValue) -> Unit)? = null ) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, SemanticsModifierNode { @Suppress("PrimitiveInCollection") @@ -157,7 +157,7 @@ internal class TextAnnotatedStringNode( softWrap: Boolean, fontFamilyResolver: FontFamily.Resolver, overflow: TextOverflow, - autoSize: AutoSize? + autoSize: TextAutoSize? ): Boolean { var changed: Boolean diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleElement.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleElement.kt index 7f174e1be46de..f3c732c57bcd4 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleElement.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleElement.kt @@ -16,8 +16,8 @@ package androidx.compose.foundation.text.modifiers -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.InspectorInfo @@ -39,7 +39,7 @@ internal class TextStringSimpleElement( private val maxLines: Int = Int.MAX_VALUE, private val minLines: Int = DefaultMinLines, private val color: ColorProducer? = null, - private val autoSize: AutoSize? = null + private val autoSize: TextAutoSize? = null ) : ModifierNodeElement() { override fun create(): TextStringSimpleNode = diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt index 34b62e65c40c7..0801ba75c73bb 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt @@ -17,8 +17,8 @@ package androidx.compose.foundation.text.modifiers import androidx.compose.foundation.internal.requirePreconditionNotNull -import androidx.compose.foundation.text.AutoSize import androidx.compose.foundation.text.DefaultMinLines +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer @@ -58,8 +58,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints.Companion.fitPrioritizingWidth -import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastRoundToInt +import kotlin.jvm.JvmName /** * Node that implements Text for [String]. @@ -77,7 +77,7 @@ internal class TextStringSimpleNode( private var maxLines: Int = Int.MAX_VALUE, private var minLines: Int = DefaultMinLines, private var overrideColor: ColorProducer? = null, - private var autoSize: AutoSize? = null + private var autoSize: TextAutoSize? = null ) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, SemanticsModifierNode { @Suppress("PrimitiveInCollection") // Map required for use in public API. // Usages of this collection are so few that the gains of using @@ -105,20 +105,36 @@ internal class TextStringSimpleNode( } /** - * Get the layout cache for the current state of the node. + * Get the layout cache for the current state of the node during layout. * * If text substitution is active, this will return the layout cache for the substitution. * Otherwise, it will return the layout cache for the original text. + * + * @receiver Current measure scope that requests the layout cache. This scope is used to update + * the density value of the returned cache. */ - private fun getLayoutCache(density: Density): ParagraphLayoutCache { - textSubstitution?.let { textSubstitutionValue -> - if (textSubstitutionValue.isShowingSubstitution) { - textSubstitutionValue.layoutCache?.let { cache -> - return cache.also { it.density = density } - } - } - } - return layoutCache.also { it.density = density } + private fun IntrinsicMeasureScope.getLayoutCacheForMeasure(): ParagraphLayoutCache { + val activeCache = getLayoutCache() + activeCache.density = this@getLayoutCacheForMeasure + return activeCache + } + + /** + * Get the layout cache for the current state of the node without updating the density. + * + * Warning; DO NOT USE this function from a MeasureScope. Instead please use + * [getLayoutCacheForMeasure]. + * + * The reason this function does not update the density value is because the density should not + * change between layout and draw phases. This is a micro optimization to skip the unnecessary + * density comparison. + * + * If text substitution is active, this will return the layout cache for the substitution. + * Otherwise, it will return the layout cache for the original text. + */ + @JvmName("getLayoutCacheOrSubstitute") + private fun getLayoutCache(): ParagraphLayoutCache { + return textSubstitution?.takeIf { it.isShowingSubstitution }?.layoutCache ?: layoutCache } fun updateDraw(color: ColorProducer?, style: TextStyle): Boolean { @@ -147,7 +163,7 @@ internal class TextStringSimpleNode( softWrap: Boolean, fontFamilyResolver: FontFamily.Resolver, overflow: TextOverflow, - autoSize: AutoSize? + autoSize: TextAutoSize? ): Boolean { var changed: Boolean @@ -343,7 +359,7 @@ internal class TextStringSimpleNode( measurable: Measurable, constraints: Constraints ): MeasureResult { - val layoutCache = getLayoutCache(this) + val layoutCache = getLayoutCacheForMeasure() val didChangeLayout = layoutCache.layoutWithConstraints(constraints, layoutDirection) // ensure measure restarts when hasStaleResolvedFonts by reading in measure @@ -383,23 +399,23 @@ internal class TextStringSimpleNode( measurable: IntrinsicMeasurable, height: Int ): Int { - return getLayoutCache(this).minIntrinsicWidth(layoutDirection) + return getLayoutCacheForMeasure().minIntrinsicWidth(layoutDirection) } override fun IntrinsicMeasureScope.minIntrinsicHeight( measurable: IntrinsicMeasurable, width: Int - ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection) + ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection) override fun IntrinsicMeasureScope.maxIntrinsicWidth( measurable: IntrinsicMeasurable, height: Int - ): Int = getLayoutCache(this).maxIntrinsicWidth(layoutDirection) + ): Int = getLayoutCacheForMeasure().maxIntrinsicWidth(layoutDirection) override fun IntrinsicMeasureScope.maxIntrinsicHeight( measurable: IntrinsicMeasurable, width: Int - ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection) + ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection) /** Optimized Text draw. */ override fun ContentDrawScope.draw() { @@ -408,7 +424,7 @@ internal class TextStringSimpleNode( return } - val layoutCache = getLayoutCache(this) + val layoutCache = getLayoutCache() val localParagraph = requirePreconditionNotNull(layoutCache.paragraph) { "no paragraph (layoutCache=$_layoutCache, textSubstitution=$textSubstitution)" diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/MultiWidgetSelectionDelegate.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/MultiWidgetSelectionDelegate.kt index ddbb17b6b96c8..3709350940169 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/MultiWidgetSelectionDelegate.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/MultiWidgetSelectionDelegate.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2020 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. @@ -16,6 +16,8 @@ package androidx.compose.foundation.text.selection +import androidx.compose.foundation.platform.makeSynchronizedObject +import androidx.compose.foundation.platform.synchronized import androidx.compose.foundation.text.getLineHeight import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -25,15 +27,13 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange import kotlin.math.max -import kotlinx.atomicfu.locks.SynchronizedObject -import kotlinx.atomicfu.locks.synchronized internal class MultiWidgetSelectionDelegate( override val selectableId: Long, private val coordinatesCallback: () -> LayoutCoordinates?, private val layoutResultCallback: () -> TextLayoutResult? ) : Selectable { - private val lock = SynchronizedObject() + private val lock = makeSynchronizedObject(this) private var _previousTextLayoutResult: TextLayoutResult? = null @@ -48,30 +48,33 @@ internal class MultiWidgetSelectionDelegate( * instance check is enough to accomplish whether a text layout has changed in a meaningful way. */ private val TextLayoutResult.lastVisibleOffset: Int - get() = synchronized(lock) { - if (_previousTextLayoutResult !== this) { - val lastVisibleLine = - when { - !didOverflowHeight || multiParagraph.didExceedMaxLines -> lineCount - 1 - else -> { // size.height < multiParagraph.height - var finalVisibleLine = - getLineForVerticalPosition(size.height.toFloat()) - .coerceAtMost(lineCount - 1) - // if final visible line's top is equal to or larger than text layout - // result's height, we need to check above lines one by one until we - // find - // a line that fits in boundaries. - while ( - finalVisibleLine >= 0 && getLineTop(finalVisibleLine) >= size.height - ) finalVisibleLine-- - finalVisibleLine.coerceAtLeast(0) + get() = + synchronized(lock) { + if (_previousTextLayoutResult !== this) { + val lastVisibleLine = + when { + !didOverflowHeight || multiParagraph.didExceedMaxLines -> lineCount - 1 + else -> { // size.height < multiParagraph.height + var finalVisibleLine = + getLineForVerticalPosition(size.height.toFloat()) + .coerceAtMost(lineCount - 1) + // if final visible line's top is equal to or larger than text + // layout + // result's height, we need to check above lines one by one until we + // find + // a line that fits in boundaries. + while ( + finalVisibleLine >= 0 && + getLineTop(finalVisibleLine) >= size.height + ) finalVisibleLine-- + finalVisibleLine.coerceAtLeast(0) + } } - } - _previousLastVisibleOffset = getLineEnd(lastVisibleLine, true) - _previousTextLayoutResult = this + _previousLastVisibleOffset = getLineEnd(lastVisibleLine, true) + _previousTextLayoutResult = this + } + _previousLastVisibleOffset } - _previousLastVisibleOffset - } override fun appendSelectableInfoToBuilder(builder: SelectionLayoutBuilder) { val layoutCoordinates = getLayoutCoordinates() ?: return diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.kt index 7b28d480fc965..07a8b1dd2e47c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.kt @@ -101,11 +101,16 @@ internal fun SelectionContainer( isEnabled = manager.isNonEmptySelection() ) - ContextMenuArea(manager) { - CompositionLocalProvider(LocalSelectionRegistrar provides registrarImpl) { - // Get the layout coordinates of the selection container. This is for hit test of - // cross-composable selection. - SimpleLayout(modifier = modifier.then(manager.modifier)) { + /* + * Need a layout for selection gestures that span multiple text children. + * + * b/372053402: SimpleLayout must be the top layout in this composable because + * the modifier argument must be applied to the top layout in case it contains + * something like `Modifier.weight`. + */ + SimpleLayout(modifier = modifier.then(manager.modifier)) { + ContextMenuArea(manager) { + CompositionLocalProvider(LocalSelectionRegistrar provides registrarImpl) { children() if ( manager.isInTouchMode && @@ -135,11 +140,12 @@ internal fun SelectionContainer( it.end.direction } - val lineHeight = if (isStartHandle) { - manager.startHandleLineHeight - } else { - manager.endHandleLineHeight - } + val lineHeight = + if (isStartHandle) { + manager.startHandleLineHeight + } else { + manager.endHandleLineHeight + } SelectionHandle( offsetProvider = positionProvider, isStartHandle = isStartHandle, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt index bbc205afd8e51..a12b23ccad770 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt @@ -595,6 +595,7 @@ internal class SelectionManager(private val selectionRegistrar: SelectionRegistr rect = rect, onCopyRequested = if (isNonEmptySelection()) ::toolbarCopy else null, onSelectAllRequested = if (isEntireContainerSelected()) null else ::selectAll, + onAutofillRequested = null ) } else if (textToolbar.status == TextToolbarStatus.Shown) { textToolbar.hide() diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt index faa6a91160195..7df2fe27faa99 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -92,6 +93,9 @@ internal class TextFieldSelectionManager(val undoManager: UndoManager? = null) { */ internal var visualTransformation: VisualTransformation = VisualTransformation.None + /** [AutofillManager] to perform clipboard features. */ + internal var autofillManager: AutofillManager? = null + /** [ClipboardManager] to perform clipboard features. */ internal var clipboardManager: ClipboardManager? = null @@ -741,6 +745,10 @@ internal class TextFieldSelectionManager(val undoManager: UndoManager? = null) { enterSelectionMode(showFloatingToolbar = true) } + internal fun autofill() { + autofillManager?.requestAutofillForActiveElement() + } + internal fun getHandlePosition(isStartHandle: Boolean): Offset { val textLayoutResult = state?.layoutResult?.value ?: return Offset.Unspecified @@ -823,12 +831,18 @@ internal class TextFieldSelectionManager(val undoManager: UndoManager? = null) { { selectAll() } } else null + val autofill: (() -> Unit)? = + if (editable && value.selection.collapsed) { + { autofill() } + } else null + textToolbar?.showMenu( rect = getContentRect(), onCopyRequested = copy, onPasteRequested = paste, onCutRequested = cut, - onSelectAllRequested = selectAll + onSelectAllRequested = selectAll, + onAutofillRequested = autofill ) } diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/DesktopOverscroll.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/Overscroll.desktop.kt similarity index 68% rename from compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/DesktopOverscroll.kt rename to compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/Overscroll.desktop.kt index bd9dcef134f38..a22b1e0c582a7 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/DesktopOverscroll.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/Overscroll.desktop.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * 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. @@ -17,7 +17,11 @@ package androidx.compose.foundation import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalAccessorScope -@OptIn(ExperimentalFoundationApi::class) @Composable -internal actual fun rememberOverscrollEffect(): OverscrollEffect = NoOpOverscrollEffect +internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? = + null + +internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? = + null diff --git a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/JsOverScroll.kt b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/Overscroll.jsWasm.kt similarity index 85% rename from compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/JsOverScroll.kt rename to compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/Overscroll.jsWasm.kt index a57c9b6899a88..b7104adef0125 100644 --- a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/JsOverScroll.kt +++ b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/Overscroll.jsWasm.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -17,23 +17,20 @@ package androidx.compose.foundation import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.CompositionLocalAccessorScope import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity -@OptIn(ExperimentalFoundationApi::class) @Composable -internal actual fun rememberOverscrollEffect(): OverscrollEffect { - return remember { - JSOverscrollEffect() - } -} +internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? = + JSOverscrollEffect() -@OptIn(ExperimentalFoundationApi::class) -private class JSOverscrollEffect() : OverscrollEffect { +internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? = + null +private class JSOverscrollEffect : OverscrollEffect { override fun applyToScroll( delta: Offset, source: NestedScrollSource, diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/Overscroll.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/Overscroll.macos.kt index f8585f3deb9a2..d79b2c666175d 100644 --- a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/Overscroll.macos.kt +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/Overscroll.macos.kt @@ -17,9 +17,11 @@ package androidx.compose.foundation import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.foundation.NoOpOverscrollEffect +import androidx.compose.runtime.CompositionLocalAccessorScope -@ExperimentalFoundationApi @Composable -internal actual fun rememberOverscrollEffect(): OverscrollEffect = NoOpOverscrollEffect \ No newline at end of file +internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? = + null + +internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? = + null diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/Clickable.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/Clickable.skiko.kt index 3b9ede84f233a..52cba998f87d9 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/Clickable.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/Clickable.skiko.kt @@ -16,12 +16,6 @@ package androidx.compose.foundation -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.KeyEventType.Companion.KeyDown -import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type import androidx.compose.ui.node.DelegatableNode // TODO(https://github.com/JetBrains/compose-multiplatform/issues/3341): support isComposeRootInScrollableContainer @@ -29,9 +23,3 @@ internal actual fun DelegatableNode .isComposeRootInScrollableContainer(): Boolean { return false } - -internal actual val KeyEvent.isPress: Boolean - get() = type == KeyDown && (key == Key.Enter || key == Key.NumPadEnter || key == Key.Spacebar) - -internal actual val KeyEvent.isClick: Boolean - get() = type == KeyUp && (key == Key.Enter || key == Key.NumPadEnter || key == Key.Spacebar) diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/platform/Synchronization.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/platform/Synchronization.skiko.kt new file mode 100644 index 0000000000000..b2cb315a5cae6 --- /dev/null +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/platform/Synchronization.skiko.kt @@ -0,0 +1,34 @@ +/* + * 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. + */ +@file:JvmName("SynchronizationKt") + +package androidx.compose.foundation.platform + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.jvm.JvmName + +internal actual class SynchronizedObject : kotlinx.atomicfu.locks.SynchronizedObject() + +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + return kotlinx.atomicfu.locks.synchronized(lock, block) +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.skiko.kt new file mode 100644 index 0000000000000..939dae902962f --- /dev/null +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.skiko.kt @@ -0,0 +1,28 @@ +/* + * 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. + */ + +package androidx.compose.foundation.text + +import androidx.compose.ui.graphics.Color +import kotlin.jvm.JvmInline + +@JvmInline +actual value class AutofillHighlight actual constructor(actual val autofillHighlightColor: Color) { + actual companion object { + // TODO https://youtrack.jetbrains.com/issue/CMP-7144/Support-Autofill-highlight-capability + actual val Default = AutofillHighlight(Color.Unspecified) + } +} diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.skiko.kt index 7810e3bdc3dec..6dd2ebe447fb1 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextPointerIcon.skiko.kt @@ -18,4 +18,7 @@ package androidx.compose.foundation.text import androidx.compose.ui.input.pointer.PointerIcon -internal actual val textPointerIcon: PointerIcon = PointerIcon.Text \ No newline at end of file +internal actual val textPointerIcon: PointerIcon = PointerIcon.Text + +// TODO https://youtrack.jetbrains.com/issue/CMP-7145/Properly-adopt-stylus-handwriting-hover-icon +internal actual val handwritingPointerIcon: PointerIcon = PointerIcon.Hand diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt index 7d44c804f9bc4..b8aa925f880d3 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.text.input.TextInputSession // TODO remove after https://youtrack.jetbrains.com/issue/COMPOSE-740/Implement-BasicTextField2 @Suppress("DEPRECATION") -@OptIn(InternalTextApi::class) @Composable internal actual fun legacyTextInputServiceAdapterAndService(): Pair @@ -82,4 +81,4 @@ internal actual fun legacyTextInputServiceAdapterAndService(): } } return adapter to service -} \ No newline at end of file +} diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt index 6d13efc2bf34f..48e4274900ac9 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt @@ -17,6 +17,7 @@ package androidx.compose.foundation.text.input.internal import androidx.compose.foundation.content.internal.ReceiveContentConfiguration +import androidx.compose.foundation.text.input.setSelectionCoerced import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.PlatformTextInputSession @@ -37,6 +38,7 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe imeOptions: ImeOptions, receiveContentConfiguration: ReceiveContentConfiguration?, onImeAction: ((ImeAction) -> Unit)?, + updateSelectionState: (() -> Unit)?, stylusHandwritingTrigger: MutableSharedFlow?, viewConfiguration: ViewConfiguration? ): Nothing { @@ -58,7 +60,7 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe state.replaceAll(newValue.text) state.editUntransformedTextAsUser { val untransformedSelection = state.mapFromTransformed(newValue.selection) - setSelection(untransformedSelection.start, untransformedSelection.end) + setSelectionCoerced(untransformedSelection.start, untransformedSelection.end) val composition = newValue.composition if (composition == null) { diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt index 5d84987f42360..ca83f4f5c1e18 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt @@ -17,20 +17,21 @@ package androidx.compose.foundation import androidx.compose.foundation.cupertino.CupertinoOverscrollEffect +import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.UiKitScrollConfig import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalAccessorScope import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -@OptIn(ExperimentalFoundationApi::class) @Composable -internal actual fun rememberOverscrollEffect(): OverscrollEffect = +internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? = rememberOverscrollEffect(applyClip = false) @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun rememberOverscrollEffect(applyClip: Boolean): OverscrollEffect = +internal fun rememberOverscrollEffect(applyClip: Boolean): OverscrollEffect? = if (UiKitScrollConfig.isRubberBandingOverscrollEnabled) { val density = LocalDensity.current.density val layoutDirection = LocalLayoutDirection.current @@ -39,5 +40,9 @@ internal fun rememberOverscrollEffect(applyClip: Boolean): OverscrollEffect = CupertinoOverscrollEffect(density, layoutDirection, applyClip) } } else { - NoOpOverscrollEffect - } \ No newline at end of file + null + } + +internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? = + // TODO https://youtrack.jetbrains.com/issue/CMP-7143/Support-OverscrollFactory-and-LocalOverscrollFactory + null diff --git a/compose/integration-tests/hero/OWNERS b/compose/integration-tests/hero/OWNERS index ee8658e218830..76f9fee20cc37 100644 --- a/compose/integration-tests/hero/OWNERS +++ b/compose/integration-tests/hero/OWNERS @@ -1,4 +1,5 @@ # Bug component: 343210 bentrengrove@google.com lelandr@google.com -ccraik@google.com \ No newline at end of file +ccraik@google.com +jossiwolf@google.com \ No newline at end of file diff --git a/compose/integration-tests/hero/README.md b/compose/integration-tests/hero/README.md new file mode 100644 index 0000000000000..fc71aef598937 --- /dev/null +++ b/compose/integration-tests/hero/README.md @@ -0,0 +1,18 @@ +# Compose Hero Benchmarks + +This module contains high-level benchmarks for Compose outlining a broader performance picture. In +comparison to component-level benchmarks, these benchmarks provide a high-level view of Compose +performance. + +## Structure +A hero benchmark consists of the following modules: + +``` +hero + - example + - example-implementation # contains the target code + - example-macrobenchmark # contains macrobenchmarks + - example-macrobenchmark-target # wrapper for example-implementation to run macrobenchmarks against + - example-microbenchmark # optional, if microbenchmarks are useful for the given hero project +``` + diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/Snack.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/Snack.kt similarity index 96% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/Snack.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/Snack.kt index cd83446989405..2ab50d27de0dd 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/Snack.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/Snack.kt @@ -1,11 +1,11 @@ /* - * Copyright 2023 The Android Open Source Project + * 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 * - * https://www.apache.org/licenses/LICENSE-2.0 + * 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, @@ -14,10 +14,9 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack +package androidx.compose.integration.hero.jetsnack.implementation import androidx.annotation.DrawableRes -import androidx.compose.integration.hero.implementation.R import androidx.compose.runtime.Immutable @Immutable diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/SnackCollection.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/SnackCollection.kt similarity index 93% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/SnackCollection.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/SnackCollection.kt index a67d565e19b64..677cba97ce81a 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/SnackCollection.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/SnackCollection.kt @@ -1,11 +1,11 @@ /* - * Copyright 2023 The Android Open Source Project + * 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 * - * https://www.apache.org/licenses/LICENSE-2.0 + * 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, @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack +package androidx.compose.integration.hero.jetsnack.implementation import androidx.compose.runtime.Immutable diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Card.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Card.kt similarity index 91% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Card.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Card.kt index 7afdaab669701..2b5e52eacc1b6 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Card.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Card.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose +package androidx.compose.integration.hero.jetsnack.implementation.compose import androidx.compose.foundation.BorderStroke -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.JetsnackTheme +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.JetsnackTheme import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/DestinationBar.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/DestinationBar.kt similarity index 90% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/DestinationBar.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/DestinationBar.kt index 6f8bb305cd33f..577d91fe16377 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/DestinationBar.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/DestinationBar.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose +package androidx.compose.integration.hero.jetsnack.implementation.compose import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.integration.hero.implementation.R -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.AlphaNearOpaque -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.JetsnackTheme +import androidx.compose.integration.hero.jetsnack.implementation.R +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.AlphaNearOpaque +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.JetsnackTheme import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Divider.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Divider.kt similarity index 92% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Divider.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Divider.kt index 60bc979c18628..631c7e6c78829 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Divider.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Divider.kt @@ -14,12 +14,12 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose +package androidx.compose.integration.hero.jetsnack.implementation.compose import android.content.res.Configuration import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.JetsnackTheme +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.JetsnackTheme import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Feed.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Feed.kt similarity index 93% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Feed.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Feed.kt index b8af6e44b029a..26f31e8c49c00 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Feed.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Feed.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose +package androidx.compose.integration.hero.jetsnack.implementation.compose import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.integration.hero.implementation.jetsnack.SnackCollection -import androidx.compose.integration.hero.implementation.jetsnack.SnackRepo +import androidx.compose.integration.hero.jetsnack.implementation.SnackCollection +import androidx.compose.integration.hero.jetsnack.implementation.SnackRepo import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Snacks.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Snacks.kt similarity index 96% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Snacks.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Snacks.kt index ded06798b65fe..288cc07043f71 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Snacks.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Snacks.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose +package androidx.compose.integration.hero.jetsnack.implementation.compose import androidx.activity.compose.ReportDrawn import androidx.annotation.DrawableRes @@ -38,10 +38,10 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.integration.hero.implementation.jetsnack.CollectionType -import androidx.compose.integration.hero.implementation.jetsnack.Snack -import androidx.compose.integration.hero.implementation.jetsnack.SnackCollection -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.JetsnackTheme +import androidx.compose.integration.hero.jetsnack.implementation.CollectionType +import androidx.compose.integration.hero.jetsnack.implementation.Snack +import androidx.compose.integration.hero.jetsnack.implementation.SnackCollection +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.JetsnackTheme import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Surface.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Surface.kt similarity index 96% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Surface.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Surface.kt index 7c73a2a5c7fda..d4075ba13671a 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/Surface.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/Surface.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose +package androidx.compose.integration.hero.jetsnack.implementation.compose import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.JetsnackTheme +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.JetsnackTheme import androidx.compose.material.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Color.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Color.kt similarity index 97% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Color.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Color.kt index ef039910d2392..7017639a31cc6 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Color.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Color.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose.theme +package androidx.compose.integration.hero.jetsnack.implementation.compose.theme import androidx.compose.ui.graphics.Color diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Shape.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Shape.kt similarity index 92% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Shape.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Shape.kt index c9f4f5157b9bb..a50b5dfa66e37 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Shape.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Shape.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose.theme +package androidx.compose.integration.hero.jetsnack.implementation.compose.theme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Shapes diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Theme.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Theme.kt similarity index 99% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Theme.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Theme.kt index 034d06b253313..2310218ad56a9 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Theme.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Theme.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose.theme +package androidx.compose.integration.hero.jetsnack.implementation.compose.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.Colors diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Type.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Type.kt similarity index 96% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Type.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Type.kt index c7c8d89fdcb46..2fe3a521517f9 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/compose/theme/Type.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/compose/theme/Type.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.compose.theme +package androidx.compose.integration.hero.jetsnack.implementation.compose.theme -import androidx.compose.integration.hero.implementation.R +import androidx.compose.integration.hero.jetsnack.implementation.R import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/views/DessertAdapter.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/views/DessertAdapter.kt similarity index 95% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/views/DessertAdapter.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/views/DessertAdapter.kt index 3894b8922d816..cadbe3c4f8d60 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/views/DessertAdapter.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/views/DessertAdapter.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.views +package androidx.compose.integration.hero.jetsnack.implementation.views import android.graphics.drawable.GradientDrawable import android.view.LayoutInflater @@ -22,8 +22,8 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView -import androidx.compose.integration.hero.implementation.R -import androidx.compose.integration.hero.implementation.jetsnack.Snack +import androidx.compose.integration.hero.jetsnack.implementation.R +import androidx.compose.integration.hero.jetsnack.implementation.Snack import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.recyclerview.widget.RecyclerView.Adapter diff --git a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/views/FeedAdapter.kt b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/views/FeedAdapter.kt similarity index 89% rename from compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/views/FeedAdapter.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/views/FeedAdapter.kt index 07c13e2e72e4f..7e9bb61a93499 100644 --- a/compose/integration-tests/hero/hero-implementation/src/main/java/androidx/compose/integration/hero/implementation/jetsnack/views/FeedAdapter.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/java/androidx/compose/integration/hero/jetsnack/implementation/views/FeedAdapter.kt @@ -14,15 +14,15 @@ * limitations under the License. */ -package androidx.compose.integration.hero.implementation.jetsnack.views +package androidx.compose.integration.hero.jetsnack.implementation.views import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.compose.integration.hero.implementation.R -import androidx.compose.integration.hero.implementation.jetsnack.CollectionType -import androidx.compose.integration.hero.implementation.jetsnack.SnackCollection +import androidx.compose.integration.hero.jetsnack.implementation.CollectionType +import androidx.compose.integration.hero.jetsnack.implementation.R +import androidx.compose.integration.hero.jetsnack.implementation.SnackCollection import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.ViewHolder diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/drawable-nodpi/donut.jpeg b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/drawable-nodpi/donut.jpeg similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/drawable-nodpi/donut.jpeg rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/drawable-nodpi/donut.jpeg diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/drawable-nodpi/eclair.jpeg b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/drawable-nodpi/eclair.jpeg similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/drawable-nodpi/eclair.jpeg rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/drawable-nodpi/eclair.jpeg diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/drawable/arrow_forward_24.xml b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/drawable/arrow_forward_24.xml similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/drawable/arrow_forward_24.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/drawable/arrow_forward_24.xml diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/font/karla_regular.ttf b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/font/karla_regular.ttf similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/font/karla_regular.ttf rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/font/karla_regular.ttf diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/layout/item_snack_card_view.xml b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/layout/item_snack_card_view.xml similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/layout/item_snack_card_view.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/layout/item_snack_card_view.xml diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/layout/item_snack_view.xml b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/layout/item_snack_view.xml similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/layout/item_snack_view.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/layout/item_snack_view.xml diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/layout/snack_feed.xml b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/layout/snack_feed.xml similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/layout/snack_feed.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/layout/snack_feed.xml diff --git a/compose/integration-tests/hero/hero-implementation/src/main/res/values/jetsnack_strings.xml b/compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/values/jetsnack_strings.xml similarity index 100% rename from compose/integration-tests/hero/hero-implementation/src/main/res/values/jetsnack_strings.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-implementation/src/main/res/values/jetsnack_strings.xml diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/AndroidManifest.xml b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/AndroidManifest.xml similarity index 89% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/AndroidManifest.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/AndroidManifest.xml index 514968900aecf..ce313a200ae2e 100644 --- a/compose/integration-tests/hero/macrobenchmark-target/src/main/AndroidManifest.xml +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ @@ -43,12 +43,12 @@ - + @@ -57,7 +57,7 @@ - + diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/IdleTracking.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/IdleTracking.kt similarity index 92% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/IdleTracking.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/IdleTracking.kt index 9fefb9f08263b..55cce87477b60 100644 --- a/compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/IdleTracking.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/IdleTracking.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.macrobenchmark.target +package androidx.compose.integration.hero.jetsnack.macrobenchmark import android.view.Choreographer import android.view.View diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack/JetsnackActivity.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target/JetsnackActivity.kt similarity index 74% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack/JetsnackActivity.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target/JetsnackActivity.kt index 6ab94904dc109..fb121398499a8 100644 --- a/compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack/JetsnackActivity.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target/JetsnackActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -14,14 +14,14 @@ * limitations under the License. */ -package androidx.compose.integration.hero.macrobenchmark.target.jetsnack +package androidx.compose.integration.hero.jetsnack.macrobenchmark.target import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.integration.hero.implementation.jetsnack.compose.Feed -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.JetsnackTheme -import androidx.compose.integration.hero.macrobenchmark.target.launchIdlenessTracking +import androidx.compose.integration.hero.jetsnack.implementation.compose.Feed +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.JetsnackTheme +import androidx.compose.integration.hero.jetsnack.macrobenchmark.launchIdlenessTracking class JetsnackActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack/JetsnackViewActivity.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target/JetsnackViewActivity.kt similarity index 85% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack/JetsnackViewActivity.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target/JetsnackViewActivity.kt index 9e14dee2b9d8b..6f76de424e84d 100644 --- a/compose/integration-tests/hero/macrobenchmark-target/src/main/java/androidx/compose/integration/hero/macrobenchmark/target/jetsnack/JetsnackViewActivity.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/target/JetsnackViewActivity.kt @@ -14,13 +14,12 @@ * limitations under the License. */ -package androidx.compose.integration.hero.macrobenchmark.target.jetsnack +package androidx.compose.integration.hero.jetsnack.macrobenchmark.target import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import androidx.compose.integration.hero.implementation.jetsnack.SnackRepo -import androidx.compose.integration.hero.implementation.jetsnack.views.FeedAdapter -import androidx.compose.integration.hero.macrobenchmark.target.R +import androidx.compose.integration.hero.jetsnack.implementation.SnackRepo +import androidx.compose.integration.hero.jetsnack.implementation.views.FeedAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/res/drawable/ic_launcher_foreground.xml b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/res/drawable/ic_launcher_foreground.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/res/layout/activity_jetsnack_view.xml b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/layout/activity_jetsnack_view.xml similarity index 100% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/res/layout/activity_jetsnack_view.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/layout/activity_jetsnack_view.xml diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/compose/integration-tests/hero/macrobenchmark-target/src/main/res/values/ic_launcher_background.xml b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/values/ic_launcher_background.xml similarity index 100% rename from compose/integration-tests/hero/macrobenchmark-target/src/main/res/values/ic_launcher_background.xml rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark-target/src/main/res/values/ic_launcher_background.xml diff --git a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/Utils.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/Utils.kt similarity index 86% rename from compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/Utils.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/Utils.kt index 871a75948a1a4..690d52031edbc 100644 --- a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/Utils.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/Utils.kt @@ -14,13 +14,12 @@ * limitations under the License. */ -package androidx.compose.integration.hero.macrobenchmark +package androidx.compose.integration.hero.jetsnack import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until -const val PACKAGE_NAME = "androidx.compose.integration.hero.macrobenchmark.target" const val ITERATIONS = 10 private const val COMPOSE_IDLE = "COMPOSE-IDLE" diff --git a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Expect.jsNative.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackConstants.kt similarity index 77% rename from compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Expect.jsNative.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackConstants.kt index 0ce7c96a251ba..a07e9ca74a72e 100644 --- a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Expect.jsNative.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackConstants.kt @@ -14,8 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui +package androidx.compose.integration.hero.jetsnack.macrobenchmark -internal actual fun classKeyForObject(a: Any): Any { - return a::class -} \ No newline at end of file +const val JETSNACK_TARGET_PACKAGE_NAME = + "androidx.compose.integration.hero.jetsnack.macrobenchmark.target" diff --git a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackFocusBenchmark.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackFocusBenchmark.kt similarity index 88% rename from compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackFocusBenchmark.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackFocusBenchmark.kt index ed517ea7f2f8e..adb31d506f193 100644 --- a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackFocusBenchmark.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackFocusBenchmark.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.macrobenchmark.jetsnack +package androidx.compose.integration.hero.jetsnack.macrobenchmark import android.content.Intent import android.view.KeyEvent.KEYCODE_TAB @@ -25,8 +25,7 @@ import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.Metric import androidx.benchmark.macro.TraceSectionMetric import androidx.benchmark.macro.junit4.MacrobenchmarkRule -import androidx.compose.integration.hero.macrobenchmark.ITERATIONS -import androidx.compose.integration.hero.macrobenchmark.PACKAGE_NAME +import androidx.compose.integration.hero.jetsnack.ITERATIONS import androidx.test.filters.LargeTest import androidx.test.uiautomator.By import androidx.test.uiautomator.Until @@ -45,7 +44,7 @@ class JetsnackFocusBenchmark(private val compilationMode: CompilationMode) { @Test fun focusHome() = benchmarkFocus( - action = "$PACKAGE_NAME.jetsnack.JETSNACK_ACTIVITY", + action = "$JETSNACK_TARGET_PACKAGE_NAME.JETSNACK_ACTIVITY", metrics = listOf( TraceSectionMetric( @@ -67,9 +66,9 @@ class JetsnackFocusBenchmark(private val compilationMode: CompilationMode) { @Test fun focusViewsHome() = benchmarkFocus( - action = "$PACKAGE_NAME.jetsnack.JETSNACK_VIEWS_ACTIVITY", + action = "$JETSNACK_TARGET_PACKAGE_NAME.JETSNACK_VIEWS_ACTIVITY", setupBlock = { - val resPkg = "androidx.compose.integration.hero.macrobenchmark.target" + val resPkg = JETSNACK_TARGET_PACKAGE_NAME val searchCondition = Until.hasObject(By.res(resPkg, "snackImageView")) // Wait until a snack collection item within the list is rendered device.wait(searchCondition, 3_000) @@ -82,7 +81,7 @@ class JetsnackFocusBenchmark(private val compilationMode: CompilationMode) { setupBlock: MacrobenchmarkScope.() -> Unit ) = benchmarkRule.measureRepeated( - packageName = PACKAGE_NAME, + packageName = JETSNACK_TARGET_PACKAGE_NAME, metrics = buildList { add(FrameTimingMetric()) diff --git a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackScrollBenchmark.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackScrollBenchmark.kt similarity index 87% rename from compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackScrollBenchmark.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackScrollBenchmark.kt index d4e9beaeb9b2b..7543d07c41827 100644 --- a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackScrollBenchmark.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackScrollBenchmark.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.macrobenchmark.jetsnack +package androidx.compose.integration.hero.jetsnack.macrobenchmark import android.content.Intent import androidx.benchmark.macro.CompilationMode @@ -23,9 +23,8 @@ import androidx.benchmark.macro.FrameTimingGfxInfoMetric import androidx.benchmark.macro.FrameTimingMetric import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.junit4.MacrobenchmarkRule -import androidx.compose.integration.hero.macrobenchmark.ITERATIONS -import androidx.compose.integration.hero.macrobenchmark.PACKAGE_NAME -import androidx.compose.integration.hero.macrobenchmark.waitForComposeIdle +import androidx.compose.integration.hero.jetsnack.ITERATIONS +import androidx.compose.integration.hero.jetsnack.waitForComposeIdle import androidx.test.filters.LargeTest import androidx.test.uiautomator.By import androidx.test.uiautomator.Direction @@ -45,7 +44,7 @@ class JetsnackScrollBenchmark(val compilationMode: CompilationMode) { @Test fun scrollHome() = benchmarkScroll( - action = "$PACKAGE_NAME.jetsnack.JETSNACK_ACTIVITY", + action = "$JETSNACK_TARGET_PACKAGE_NAME.JETSNACK_ACTIVITY", measureBlock = { val searchCondition = Until.hasObject(By.res("snack_collection")) // Wait until a snack collection item within the list is rendered @@ -59,9 +58,9 @@ class JetsnackScrollBenchmark(val compilationMode: CompilationMode) { @Test fun scrollViewsHome() = benchmarkScroll( - action = "$PACKAGE_NAME.jetsnack.JETSNACK_VIEWS_ACTIVITY", + action = "$JETSNACK_TARGET_PACKAGE_NAME.JETSNACK_VIEWS_ACTIVITY", measureBlock = { - val resPkg = "androidx.compose.integration.hero.macrobenchmark.target" + val resPkg = JETSNACK_TARGET_PACKAGE_NAME val searchCondition = Until.hasObject(By.res(resPkg, "snackImageView")) // Wait until a snack collection item within the list is rendered device.wait(searchCondition, 3_000) @@ -74,7 +73,7 @@ class JetsnackScrollBenchmark(val compilationMode: CompilationMode) { @OptIn(ExperimentalMetricApi::class) private fun benchmarkScroll(action: String, measureBlock: MacrobenchmarkScope.() -> Unit) = benchmarkRule.measureRepeated( - packageName = PACKAGE_NAME, + packageName = JETSNACK_TARGET_PACKAGE_NAME, metrics = listOf(FrameTimingMetric(), FrameTimingGfxInfoMetric()), compilationMode = compilationMode, iterations = ITERATIONS, diff --git a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackStartupBenchmark.kt b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackStartupBenchmark.kt similarity index 82% rename from compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackStartupBenchmark.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackStartupBenchmark.kt index 6315cdc49dd12..05ad2ed8d5b8d 100644 --- a/compose/integration-tests/hero/macrobenchmark/src/main/java/androidx/compose/integration/hero/macrobenchmark/jetsnack/JetsnackStartupBenchmark.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-macrobenchmark/src/main/java/androidx/compose/integration/hero/jetsnack/macrobenchmark/JetsnackStartupBenchmark.kt @@ -14,12 +14,11 @@ * limitations under the License. */ -package androidx.compose.integration.hero.macrobenchmark.jetsnack +package androidx.compose.integration.hero.jetsnack.macrobenchmark import androidx.benchmark.macro.CompilationMode import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.junit4.MacrobenchmarkRule -import androidx.compose.integration.hero.macrobenchmark.PACKAGE_NAME import androidx.test.filters.LargeTest import androidx.testutils.createStartupCompilationParams import androidx.testutils.measureStartup @@ -37,14 +36,15 @@ class JetsnackStartupBenchmark(val startupMode: StartupMode, val compilationMode benchmarkRule.measureStartup( compilationMode = compilationMode, startupMode = startupMode, - packageName = PACKAGE_NAME + packageName = JETSNACK_TARGET_PACKAGE_NAME ) { this.action = action } - @Test fun startup() = measureStartup("$PACKAGE_NAME.jetsnack.JETSNACK_ACTIVITY") + @Test fun startup() = measureStartup("$JETSNACK_TARGET_PACKAGE_NAME.JETSNACK_ACTIVITY") - @Test fun startupViews() = measureStartup("$PACKAGE_NAME.jetsnack.JETSNACK_VIEWS_ACTIVITY") + @Test + fun startupViews() = measureStartup("$JETSNACK_TARGET_PACKAGE_NAME.JETSNACK_VIEWS_ACTIVITY") companion object { @Parameterized.Parameters(name = "startup={0},compilationMode={1}") diff --git a/compose/integration-tests/hero/benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack/JetsnackBenchmark.kt b/compose/integration-tests/hero/jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark/JetsnackBenchmark.kt similarity index 94% rename from compose/integration-tests/hero/benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack/JetsnackBenchmark.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark/JetsnackBenchmark.kt index e569f4340ace6..ddfc90db890b0 100644 --- a/compose/integration-tests/hero/benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack/JetsnackBenchmark.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark/JetsnackBenchmark.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.integration.hero.benchmark.jetsnack +package androidx.compose.integration.hero.jetsnack.microbenchmark import androidx.compose.testutils.benchmark.ComposeBenchmarkRule import androidx.compose.testutils.benchmark.benchmarkDrawPerf diff --git a/compose/integration-tests/hero/benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack/JetsnackCaseFactory.kt b/compose/integration-tests/hero/jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark/JetsnackCaseFactory.kt similarity index 82% rename from compose/integration-tests/hero/benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack/JetsnackCaseFactory.kt rename to compose/integration-tests/hero/jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark/JetsnackCaseFactory.kt index cd4680a8a4c86..40ce0941e85e7 100644 --- a/compose/integration-tests/hero/benchmark/src/androidTest/java/androidx/compose/integration/hero/benchmark/jetsnack/JetsnackCaseFactory.kt +++ b/compose/integration-tests/hero/jetsnack/jetsnack-microbenchmark/src/androidTest/java/androidx/compose/integration/hero/jetsnack/microbenchmark/JetsnackCaseFactory.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package androidx.compose.integration.hero.benchmark.jetsnack +package androidx.compose.integration.hero.jetsnack.microbenchmark -import androidx.compose.integration.hero.implementation.jetsnack.compose.Feed -import androidx.compose.integration.hero.implementation.jetsnack.compose.theme.JetsnackTheme +import androidx.compose.integration.hero.jetsnack.implementation.compose.Feed +import androidx.compose.integration.hero.jetsnack.implementation.compose.theme.JetsnackTheme import androidx.compose.runtime.Composable import androidx.compose.testutils.LayeredComposeTestCase diff --git a/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/TrivialStartupBenchmark.kt b/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/TrivialStartupBenchmark.kt index 6c78f720d9b78..d4f1ba666bfa7 100644 --- a/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/TrivialStartupBenchmark.kt +++ b/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/TrivialStartupBenchmark.kt @@ -40,7 +40,7 @@ class TrivialStartupBenchmark( benchmarkRule.measureStartup( compilationMode = compilationMode, startupMode = startupMode, - packageName = "androidx.compose.integration.macrobenchmark.target" + packageName = "androidx.compose.integration.macrobenchmark.target", ) { action = "androidx.compose.integration.macrobenchmark.target.TRIVIAL_STARTUP_ACTIVITY" } diff --git a/compose/lint/common-test/src/main/java/androidx/compose/lint/test/Stubs.kt b/compose/lint/common-test/src/main/java/androidx/compose/lint/test/Stubs.kt index d01e74896016f..96a6a6a57c9f1 100644 --- a/compose/lint/common-test/src/main/java/androidx/compose/lint/test/Stubs.kt +++ b/compose/lint/common-test/src/main/java/androidx/compose/lint/test/Stubs.kt @@ -1460,6 +1460,143 @@ object Stubs { RlEikLgj4eZ2WcKWWJFYlaj8BpHq1rYeAwAA """ ) + + val CompositionLocal = + bytecodeStub( + filename = "CompositionLocal.kt", + filepath = "androidx/compose/runtime", + checksum = 0xa5bf3022, + """ + package androidx.compose.runtime + + sealed class CompositionLocal constructor(defaultFactory: (() -> T)? = null) { + val stubValue: T = defaultFactory!!.invoke() + + inline val current: T + @Composable get() = stubValue + } + + abstract class ProvidableCompositionLocal internal constructor( + defaultFactory: (() -> T)? + ) : CompositionLocal(defaultFactory) + + internal class DynamicProvidableCompositionLocal constructor( + defaultFactory: (() -> T)? + ) : ProvidableCompositionLocal(defaultFactory) + + internal class StaticProvidableCompositionLocal( + defaultFactory: (() -> T)? + ) : ProvidableCompositionLocal(defaultFactory) + + fun compositionLocalOf( + defaultFactory: (() -> T)? = null + ): ProvidableCompositionLocal = DynamicProvidableCompositionLocal(defaultFactory) + + fun staticCompositionLocalOf( + defaultFactory: (() -> T)? = null + ): ProvidableCompositionLocal = StaticProvidableCompositionLocal(defaultFactory) + """, + """ + META-INF/main.kotlin_module: + H4sIAAAAAAAA/2NgYGBmYGBgBGJOBijg0uOSSMxLKcrPTKnQS87PLcgvTtUr + Ks0rycxNFRJyBgtklmTm5/nkJyfmeJdw6XPJYKgvzdQryEksScsvyhXid4TI + gtUXAzWIcnED1emlViTmFuSkCrGFpBaXeJcoMWgxAACzjsPdkAAAAA== + """, + """ + androidx/compose/runtime/CompositionLocal.class: + H4sIAAAAAAAA/5VUXW/bVBh+juPEiZu2brKOfmylZdnIBzRZ2UZZQseWUQhK + O0ijSKgXyHXc7jSJPfnY0biZKm74DdzyC5gEFLhA1bjjR017j5N2XVsUJkvn + nPfxe573eT/sf1/+9TeAW/iKIWc6bc/l7adFy+09cYVd9ALH5z27WA1t7nPX + qbuW2dXAGLKV5t36vtk3i13T2Ss+2tm3Lb+8dh5iMM5iGlSGWIU73F9juJat + d1y/y53ifr9X3A0cS0YSxfXhqVTOtUjeKK9Kodksr4W+kWyulUQMCR1R6AwL + p25yx7c9x+wWa47vcUdwS2hIMiStx7bV2XT9zaDbZZjOns9Ekk5gUsc4jDdJ + L5CjIUU5cqfvdmyGS9nceb4kLmF6DGlcZkgIP9hpmd2AnFMXuc5gNgEFcwyq + /5gLhkL9fzeMejDRtnfNoOuvm5bvet8zLI4qOkNjZGNq9Ysq+3AQqkq+vhfI + eBum17G9sDffjCR9W07Z6SWdavMedXHP9rdeVzKazdFQMOgEVwPPsx2f4fqo + wpk7XZsqnsG0ZM0yTGZ4ZjdzmoPVZFRNvv+AIZ2xXuv6rhcKY1h+u0RobEOt + U8fXNmzfbJu+SZjS60foO2VyScgFJKFD+FMurRKd2jcZnh0dzOjKjKIfHeiK + QYvcyVTimtzjd2aODvJq/OjAYCtKSXkwG1dTRlwxInN6Sk0pq3SdldQXP8cU + I9pIGbE5Ca3+86NCUGxOjWtGvJE2EiFMkE5BxgjWjfiLHxhLShUrJKzJpL7U + cR6nR0qzjit4+7+78LXn9nlbduGCQU6fxZY7xJbY4nuO6QceNV2tum3aJuvc + sTeD3o7tNSWXVCT9W6bHpT0Ex7d80+psmE+G9nxjoKLm9LngBN13HNc3wyGl + QdpyA8+y17l0nR26ts45qks0GNGwTSn55dL+gCwFJUIVOTm0Vgl5hAidgHT+ + EGP5wrd/YOpPvMPwG+afhxce0joB2WSVnijRqficrMuDa7iCq3Ic6LSAd0/C + xOn/FMMi2dXw9x6hW8DkFfXZT4iyer7ADnFtEGCd1ghYPIykh6wqRUxgSn4A + Q5lFOW+0R/O/Yv6XE2GxARgKSg7PA0EDGdeJ5AaRREKSMnlIei1Syf+O3Js0 + pPkkL1m6PAohoXaG8Iuh//tAmOPSMMdVeiurFMsXDvHh87D0km9xgJ7UKTas + kzwtU17y1oA7gi/D/T5qtDfI5ybpWNlGpIaParhVw23coSM+rlG0T7bBBO6i + vI1xgasCFYFPBRYE1gQ0gXsCNwSmBWYEMgKzAp+9AtxYnCVtBwAA + """, + """ + androidx/compose/runtime/CompositionLocalKt.class: + H4sIAAAAAAAA/51UW08TQRT+ZntfCpSi0gsoQlUuyhYQVKgkBkNsKJdIgzE8 + TbcLmV52ye62gRfD3/Bf+KbRxPTZH2U8sy1RiwXTlzNn5nzznW/PObM/fn79 + DuApnjPMc7NsW6J8pulW/dRyDM1umK6oG9qmtxeusMyCpfPathsCY4hVeJNr + NW6eaHuliqHTqY8hrneh944Z3s0UqpZbE6ZWada144apy7CjbXW87PpsoWf6 + fdtqijIv1YxuIesMZ7niWqFbyPrGTfly88Xi+kZ/WXPeVYbpgmWfaBXDLdlc + EDk3Tcvl7US7lrvbqEmBmetQBJEZCLbWU8jrc5PXhd5bTwgqQzAnTOFukKqb + S30YRRSDKgYwxLDcRwVCiDEMlY1j3qi5W1x3LfucYfKmxAypq8OR6dAwVG5U + nr/a6f7mJooAgioU3GFIOLIdejdGju2LntwH3p3rKpRUkZL1nexFf/nhUSTa + WiYYRi4rsGO4vMxdTjVT6k0fvVEmTUQaMLCqdBQKngnpZckrLzKUWxcxtXWh + KglFVcKKt7YuUpkYmXDcH1feKFk25Q8TTFmKhZWYL6W2jxMs6ydc4D9gMtcS + w0qfL5YV6Sdx+Z1/DsdoN3ihSlPh37TKBsNwQZjGbqNeMuyiJJYcEnPIbSH3 + ncPIgTgxuduwyU+/bcvJm03hCAq/+v306F12R/e5zeuGa9h/wQap03p1h592 + EqgHVsPWjS0hN8kOx+EVfixSQ/2yWWSTctpo1Wi3Ch/1D4h+w8D7uc8YbmHk + k+wlsmSDXixJtwnRxiGOUVqXPEwIyx1U2PttAyEqKCJALIJbuE1+O4kCORaD + af+Hjwiw7bkvGGtnWSFLCsJeuiEPNUaECSIco8SJfwlNSaHpfwid6E/o+LVC + 7/YUmibCcSJMU3jVAy3gGa0vie0e1XjyCL487ucxlcc0Mnk8wMM8HmHmCMzB + LOaOEHQQcDDv4LGDuIMnDhK/AIiJIGwEBwAA + """, + """ + androidx/compose/runtime/DynamicProvidableCompositionLocal.class: + H4sIAAAAAAAA/51S308TQRD+9lpaOKG0RRDwByhoBBKvoCaG1iaKITSp2EjT + F562dwtuudszd3sNvPVv8T/wycQH0/joH2WcbUtEE9KEl5lvZr/5dmZ2f/3+ + /gPACzxh2OXKi0LpnTtuGHwOY+FEidIyEM67C8UD6TaisCs93vbF3oAgtQxV + PXS5nwVjaFSau/UO73LH5+rU+dDuCFeXq/VrZa/XqzSb5WqZ4fkNarNIM2Qq + UkldZVh7Wj8LtS+V0+kGzkmiXEOMnf0RKpU3Wgwb41iVrUFHhrteD6NTpyN0 + O+KSOFypUPMh/zDxfdNTeRoZZG1MwGZI608yZqhcv4ix+6VV5DxxwhNf73NX + h9EFw+q4wRgKl5T3QnOPa045K+im6MWZMVPGgIGdUf5cmqhEyNtmOOj3ira1 + aNn93r9ust9b7Pc20+TzbGeymC5aB6xkvZ0v5vKpZdvEr4jCSumfXzJWfsLo + 7dAVTYaXN/kK1HLxcoyrs839T3x2pmnXe6EnGGbrUonDJGiLqGlEjYbhtHgk + TTxKTh3JU8V1EhFe/zhspaa6MpZ03OARD4QW0Zu/D8xgH4VJ5Ip9aeqXRjWt + YcUVIrZh0euP1ms+A1JYoahKeYt8ZnPrG259JWRhlaw9yBaoZhYPCS0MWZjG + zEAlgxydMDwaVExijXzWSE8RSI3SKawP/AM8Jv+aTvMkWDhGqoZiDXM13MY8 + QSzUcAeLx2AxlrB8jEyMmRh3Y9yLkYtxP0b2D5OoO78aBAAA + """, + """ + androidx/compose/runtime/ProvidableCompositionLocal.class: + H4sIAAAAAAAA/41SXU9TQRA9e1vacsFSikLBLxBEgcRbURNjK0YxjTUFURpe + eNr2Lrjt7V6zd2+Db/0t/gOfTHwwjY/+KONsWyKSEHzZmT1z5szszP76/f0H + gMe4x/CIK1+H0j/xmmHnUxgJT8fKyI7w9nTYlT5vBGJ7EJFGhqoWNnmQBmOo + lOvPai3e5V7A1bH3rtESTVPaql2od16lXK+XtkoMa/+dkUaSIVWWSpothuX7 + tXZoAqm8VrfjHcWqaYmRVxl5xdLaAalfxipvDPqw3JVaqI+9ljANzSVxuFKh + 4UP+bhwEdhbU8PtLC5+NS2WEVjzwXosjHgdmm6hGx00T6h2u20JT6Umk4LoY + wwRD0nyUEcOTiwd58WKouaw/LFPhtsJnhsXLmmWYPqXsCMN9bjhhTqeboD/C + 7DFuDzCwNuEn0t6K5PkPGd72e3nXKThuv/evyawW+r31ZKbfy7HNTD6Zd96w + ovNqjoB8NpdYcC30tN8rsGLy55eUkxuziptUpM6w8f+/iFrNn7Z/9k0z54kP + 2oaGux36gmGqJpXYjTsNoet2jlbDcg64lvY+Asf35bHiJtbkr3wYNlBVXRlJ + Cu9xzTuCdvvy7y9hcPfDWDdFRdr8+VHOwTDjDDG5BIfWPRorbT+NBBbp9oKs + Qza9vsG+YfIruQ6W6HQH8BWiTuAOebNDGiHZgUwaU8iR1PIgI4MVi1ntcXIS + IziBuwN7G6tkn1N0mrrIHyJRxUwVV6u4hllyMVdFAfOHYBEWcP0QqQjZCDci + 3IwwFeFWhPQfROtXvEUEAAA= + """, + """ + androidx/compose/runtime/StaticProvidableCompositionLocal.class: + H4sIAAAAAAAA/51SXW8SQRQ9s1A+1pZSkNrWj1aLxraJS6smKkiiTZqSYG2E + 8MLTsDvFgWXX7M6S+sZv8R/4ZOKDIT76o4x3gMZqQpr05d5z75x75t478+v3 + 9x8AnuERw0vuOYEvnXPL9gef/FBYQeQpORBWQ3El7dPAH0qHd1xxODmXSvpe + 3be5mwRjOK00X9V7fMgtl3td632nJ2xVrtbnqs7XqzSb5WqZ4ek1apOIMyQq + 0pOqyrD9uN73lSs9qzccWGeRZ2tiaB3NUKm802LYuYpV2Zt0pLnFuh90rZ5Q + nYBL4nDP8/V6NP8kcl3dU3kRCSRNLMBkiKuPMmQoz1/EVeulTWQcccYjVx1x + W/nBZ4atq+ZiWLmgvBOKO1xxyhmDYYzem2mT1gYMrE/5c6mjEiFnn+F4PMqZ + xpphjkf/utR4tDYe7cbJZ9lBKhfPGcesZLwt5DLZ2Iap4xdEYaX4zy8JI7ug + 9Q7oiibD8+v8BGo5dzHG5dny/xOf9BWt+tB3BMNyXXriJBp0RNDUolpDc1o8 + kDqeJdMN2fW4igLCxQ/TVmreUIaSjk95wAdCieDN3/dlMBt+FNjiSOr69VlN + a1pxiYh9GPT4s/Xqv4AYNimqUt4gn9jd+4YbXwkZ2CJrTrIFqsnjPqHVKQuL + WJqoJJDBMik9mFSksE0+qaXTBGKzdAzFib+Hh+Rf02mWBFfaiNWQqyFfw00U + CGK1hltYa4OFWMdGG4kQSyFuh7gTIhPibojkH2XEsYYYBAAA + """ + ) } /** diff --git a/compose/lint/internal-lint-checks/src/main/java/androidx/compose/lint/PrimitiveInCollectionDetector.kt b/compose/lint/internal-lint-checks/src/main/java/androidx/compose/lint/PrimitiveInCollectionDetector.kt index 694892d5fcc8e..04060eba86f14 100644 --- a/compose/lint/internal-lint-checks/src/main/java/androidx/compose/lint/PrimitiveInCollectionDetector.kt +++ b/compose/lint/internal-lint-checks/src/main/java/androidx/compose/lint/PrimitiveInCollectionDetector.kt @@ -34,6 +34,7 @@ import com.intellij.psi.PsiPrimitiveType import com.intellij.psi.PsiWildcardType import com.intellij.psi.impl.source.PsiClassReferenceType import java.util.EnumSet +import org.jetbrains.kotlin.asJava.elements.KtLightField import org.jetbrains.kotlin.psi.KtDestructuringDeclaration import org.jetbrains.kotlin.psi.KtParameter import org.jetbrains.uast.UElement @@ -255,20 +256,29 @@ private fun JvmType.primitiveName(): String? = private fun PsiClassReferenceType.toPrimitiveName(): String? { val resolvedType = resolve() ?: return null if (hasJvmInline(resolvedType)) { - val constructorParam = - resolvedType.constructors.firstOrNull { it.parameters.size == 1 }?.parameters?.first() + // Depending on where the inline class is coming from, there are a couple places to check + // for what the value is. + val valueType = + // For value classes in this compilation, find the field which corresponds to the value + // class constructor parameter (the constructor is not modeled in k2 psi). There may be + // other fields (a companion object and companion object fields). + resolvedType.fields + .singleOrNull { (it as? KtLightField)?.kotlinOrigin is KtParameter } + ?.type + // For value classes from a different compilation, the fields may not have a kotlin + // origin attached, so there's nothing to differentiate companion fields and the + // value class field. Instead find the "constructor-impl" method (which is not + // present for value classes from this compilation). ?: resolvedType.methods .firstOrNull { it.parameters.size == 1 && it.name == "constructor-impl" } ?.parameters ?.first() - if (constructorParam != null) { - val type = constructorParam.type - if (type is PsiPrimitiveType) { - return BoxedTypeToSuggestedPrimitive[type.boxedTypeName] - } - if (type is PsiClassReferenceType) { - return type.toPrimitiveName() - } + ?.type + if (valueType is PsiPrimitiveType) { + return BoxedTypeToSuggestedPrimitive[valueType.boxedTypeName] + } + if (valueType is PsiClassReferenceType) { + return valueType.toPrimitiveName() } } return BoxedTypeToSuggestedPrimitive[resolvedType.qualifiedName] diff --git a/compose/lint/internal-lint-checks/src/test/java/androidx/compose/lint/PrimitiveInCollectionDetectorTest.kt b/compose/lint/internal-lint-checks/src/test/java/androidx/compose/lint/PrimitiveInCollectionDetectorTest.kt index 2c04bed41976b..c3da4a66e735e 100644 --- a/compose/lint/internal-lint-checks/src/test/java/androidx/compose/lint/PrimitiveInCollectionDetectorTest.kt +++ b/compose/lint/internal-lint-checks/src/test/java/androidx/compose/lint/PrimitiveInCollectionDetectorTest.kt @@ -513,7 +513,11 @@ src/androidx/compose/lint/Foo.kt:4: Error: return type Set<$longType> of getFoo: """ package androidx.compose.lint - @JvmInline value class ContainsInt(val value: Int) + @JvmInline value class ContainsInt(val value: Int) { + companion object { + val companionField = 0 + } + } """ .trimIndent() ) diff --git a/compose/material/material-navigation/api/desktop/material-navigation.api b/compose/material/material-navigation/api/desktop/material-navigation.api index 8d6083e910a00..4fffec8d51e38 100644 --- a/compose/material/material-navigation/api/desktop/material-navigation.api +++ b/compose/material/material-navigation/api/desktop/material-navigation.api @@ -46,6 +46,8 @@ public final class androidx/compose/material/navigation/ComposableSingletons$Bot public final class androidx/compose/material/navigation/NavGraphBuilderKt { public static final fun bottomSheet (Landroidx/navigation/NavGraphBuilder;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function4;)V + public static final fun bottomSheet (Landroidx/navigation/NavGraphBuilder;Lkotlin/reflect/KClass;Ljava/util/Map;Ljava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function4;)V public static synthetic fun bottomSheet$default (Landroidx/navigation/NavGraphBuilder;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V + public static synthetic fun bottomSheet$default (Landroidx/navigation/NavGraphBuilder;Lkotlin/reflect/KClass;Ljava/util/Map;Ljava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V } diff --git a/compose/material/material-navigation/api/restricted_current.txt b/compose/material/material-navigation/api/restricted_current.txt index dd70defb772c8..1dbe896645628 100644 --- a/compose/material/material-navigation/api/restricted_current.txt +++ b/compose/material/material-navigation/api/restricted_current.txt @@ -39,6 +39,7 @@ package androidx.compose.material.navigation { public final class NavGraphBuilderKt { method public static void bottomSheet(androidx.navigation.NavGraphBuilder, String route, optional java.util.List arguments, optional java.util.List deepLinks, kotlin.jvm.functions.Function2 content); method public static inline void bottomSheet(androidx.navigation.NavGraphBuilder, optional java.util.Map> typeMap, optional java.util.List arguments, optional java.util.List deepLinks, kotlin.jvm.functions.Function2 content); + method @kotlin.PublishedApi internal static void bottomSheet(androidx.navigation.NavGraphBuilder, kotlin.reflect.KClass route, java.util.Map> typeMap, java.util.List arguments, optional java.util.List deepLinks, kotlin.jvm.functions.Function2 content); } } diff --git a/compose/material/material-navigation/src/commonMain/kotlin/androidx/compose/material/navigation/NavGraphBuilder.kt b/compose/material/material-navigation/src/commonMain/kotlin/androidx/compose/material/navigation/NavGraphBuilder.kt index 9ecaa4b72383f..f802f25b243d5 100644 --- a/compose/material/material-navigation/src/commonMain/kotlin/androidx/compose/material/navigation/NavGraphBuilder.kt +++ b/compose/material/material-navigation/src/commonMain/kotlin/androidx/compose/material/navigation/NavGraphBuilder.kt @@ -72,11 +72,22 @@ public inline fun NavGraphBuilder.bottomSheet( arguments: List = emptyList(), deepLinks: List = emptyList(), noinline content: @Composable ColumnScope.(backstackEntry: NavBackStackEntry) -> Unit +) { + bottomSheet(T::class, typeMap, arguments, deepLinks, content) +} + +@PublishedApi +internal fun NavGraphBuilder.bottomSheet( + route: KClass<*>, + typeMap: Map>, + arguments: List, + deepLinks: List = emptyList(), + content: @Composable ColumnScope.(backstackEntry: NavBackStackEntry) -> Unit ) { destination( BottomSheetNavigatorDestinationBuilder( provider[BottomSheetNavigator::class], - T::class, + route, typeMap, content ) diff --git a/compose/material/material-ripple/src/androidInstrumentedTest/kotlin/androidx/compose/material/ripple/RippleModifierNodeTest.kt b/compose/material/material-ripple/src/androidInstrumentedTest/kotlin/androidx/compose/material/ripple/RippleModifierNodeTest.kt index f120107f4830e..3e9b8f89a5c96 100644 --- a/compose/material/material-ripple/src/androidInstrumentedTest/kotlin/androidx/compose/material/ripple/RippleModifierNodeTest.kt +++ b/compose/material/material-ripple/src/androidInstrumentedTest/kotlin/androidx/compose/material/ripple/RippleModifierNodeTest.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -47,11 +48,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.test.captureToImage @@ -79,11 +82,7 @@ import org.junit.runner.RunWith // first so each layer will have the expected alpha to ensure that the minimum contrast in // areas where the ripples don't overlap is still correct - as a result the colors aren't // exactly what we expect here so we can't really reliably assert - minSdkVersion = Build.VERSION_CODES.P, - // On S and above, the press ripple is patterned and has inconsistent behaviour in terms of - // alpha, so it doesn't behave according to our expectations - we can't explicitly assert on the - // color. - maxSdkVersion = Build.VERSION_CODES.R + minSdkVersion = Build.VERSION_CODES.P ) class RippleModifierNodeTest { @@ -344,7 +343,8 @@ class RippleModifierNodeTest { * * Note: no corresponding test for pressed ripples since RippleForeground does not update the * color of currently active ripples unless they are being drawn on the UI thread (which should - * only happen if the target radius also changes). + * only happen if the target radius also changes, which only happens for a bounds change when + * the ripple radius is calculated by the framework). */ @Test fun colorChangeDuringRipple_dragged() { @@ -476,6 +476,161 @@ class RippleModifierNodeTest { } } + /** + * Test case for increasing the bounds of a ripple while pressed. Above S, the ripple should + * expand to fill the expanding bounds, even though the radius was initially calculated with the + * original smaller bounds. + * + * Note: no corresponding test for bounds decreasing, since there is no issue in such a case: + * the radius is already big enough to fill the smaller space. + */ + // Below S bounds changes won't update an existing ripple with an explicitly set radius, so the + // ripple will not expand - we can only support this functionality on S+. + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) + @Test + fun boundsIncreaseDuringRipple_pressed() { + val interactionSource = MutableInteractionSource() + + var scope: CoroutineScope? = null + + var size by mutableStateOf(400) + + val ripple = TestIndicationNodeFactory({ TestRippleColor }, { TestRippleAlpha }) + + rule.setContent { + scope = rememberCoroutineScope() + Box( + Modifier.fillMaxSize().background(RippleBoxBackgroundColor), + contentAlignment = Alignment.Center + ) { + Box( + Modifier.size(with(LocalDensity.current) { size.toDp() }) + .semantics(mergeDescendants = true) {} + .testTag(Tag) + .clip(RectangleShape) + .indication(interactionSource = interactionSource, indication = ripple) + ) + } + } + + rule.runOnIdle { + scope!!.launch { interactionSource.emit(PressInteraction.Press(Offset(10f, 10f))) } + } + rule.waitForIdle() + + @Suppress("BanThreadSleep") + // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for + // synchronization. Instead just wait until after the ripples are finished animating. + Thread.sleep(300) + + fun assertPixelColors(expectedSize: Int) { + with(rule.onNodeWithTag(Tag)) { + val bitmap = captureToImage().asAndroidBitmap() + Truth.assertThat(bitmap.width).isEqualTo(expectedSize) + Truth.assertThat(bitmap.height).isEqualTo(expectedSize) + with(bitmap) { + val center = Color(getPixel(width / 2, height / 2)) + val topLeft = Color(getPixel(20, 20)) + val topRight = Color(getPixel(width - 20, 20)) + val bottomLeft = Color(getPixel(20, height - 20)) + val bottomRight = Color(getPixel(width - 20, height - 20)) + + // On S and above, the press ripple is patterned and has inconsistent behaviour + // in terms of alpha, so it doesn't behave according to our expectations - we + // can't explicitly assert on the color. Instead we just assert that it is not + // the background color, to make sure that the ripple is rendering something + // over the whole background. + Truth.assertThat(center).isNotEqualTo(RippleBoxBackgroundColor) + // Important to assert the corners, as that is where the ripple needs to expand + // to fill. + Truth.assertThat(topLeft).isNotEqualTo(RippleBoxBackgroundColor) + Truth.assertThat(topRight).isNotEqualTo(RippleBoxBackgroundColor) + Truth.assertThat(bottomLeft).isNotEqualTo(RippleBoxBackgroundColor) + Truth.assertThat(bottomRight).isNotEqualTo(RippleBoxBackgroundColor) + } + } + } + + assertPixelColors(size) + + val newSize = 800 + + rule.runOnUiThread { size = newSize } + rule.waitForIdle() + + assertPixelColors(newSize) + } + + /** + * Test case for increasing the bounds of a ripple while dragged - the state layer should fill + * the new bounds. + */ + @Test + fun boundsIncreaseDuringRipple_dragged() { + val interactionSource = MutableInteractionSource() + + var scope: CoroutineScope? = null + + var size by mutableStateOf(400) + + val ripple = TestIndicationNodeFactory({ TestRippleColor }, { TestRippleAlpha }) + + rule.setContent { + scope = rememberCoroutineScope() + Box( + Modifier.fillMaxSize().background(RippleBoxBackgroundColor), + contentAlignment = Alignment.Center + ) { + Box( + Modifier.size(with(LocalDensity.current) { size.toDp() }) + .semantics(mergeDescendants = true) {} + .testTag(Tag) + .clip(RectangleShape) + .indication(interactionSource = interactionSource, indication = ripple) + ) + } + } + + rule.runOnIdle { scope!!.launch { interactionSource.emit(DragInteraction.Start()) } } + rule.waitForIdle() + + fun assertPixelColors(expectedSize: Int) { + with(rule.onNodeWithTag(Tag)) { + val bitmap = captureToImage().asAndroidBitmap() + Truth.assertThat(bitmap.width).isEqualTo(expectedSize) + Truth.assertThat(bitmap.height).isEqualTo(expectedSize) + with(bitmap) { + val center = Color(getPixel(width / 2, height / 2)) + val topLeft = Color(getPixel(20, 20)) + val topRight = Color(getPixel(width - 20, 20)) + val bottomLeft = Color(getPixel(20, height - 20)) + val bottomRight = Color(getPixel(width - 20, height - 20)) + + val expectedColor = + calculateResultingRippleColor( + TestRippleColor, + rippleOpacity = TestRippleAlpha.draggedAlpha + ) + + Truth.assertThat(center).isEqualTo(expectedColor) + Truth.assertThat(topLeft).isEqualTo(expectedColor) + Truth.assertThat(topRight).isEqualTo(expectedColor) + Truth.assertThat(bottomLeft).isEqualTo(expectedColor) + Truth.assertThat(bottomRight).isEqualTo(expectedColor) + } + } + } + + assertPixelColors(size) + + val newSize = 800 + + rule.runOnUiThread { size = newSize } + rule.waitForIdle() + + assertPixelColors(newSize) + } + /** * Asserts that the resultant color of the ripple on screen matches [expectedCenterPixelColor]. * @@ -511,11 +666,20 @@ class RippleModifierNodeTest { // Compare expected and actual pixel color val centerPixel = - rule.onNodeWithTag(Tag).captureToImage().asAndroidBitmap().run { - getPixel(width / 2, height / 2) - } + Color( + rule.onNodeWithTag(Tag).captureToImage().asAndroidBitmap().run { + getPixel(width / 2, height / 2) + } + ) - Truth.assertThat(Color(centerPixel)).isEqualTo(expectedCenterPixelColor) + // On S and above, the press ripple is patterned and has inconsistent behaviour in terms of + // alpha, so it doesn't behave according to our expectations - we can't explicitly assert on + // the color. Instead we just assert that it is not the background color + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && interaction is PressInteraction) { + Truth.assertThat(centerPixel).isNotEqualTo(RippleBoxBackgroundColor) + } else { + Truth.assertThat(centerPixel).isEqualTo(expectedCenterPixelColor) + } } } @@ -565,6 +729,6 @@ private val TestRippleColor = Color.Red private val TestRippleAlpha = RippleAlpha(draggedAlpha = 0.1f, focusedAlpha = 0.2f, hoveredAlpha = 0.3f, pressedAlpha = 0.4f) -private val RippleBoxBackgroundColor = Color.Blue +private val RippleBoxBackgroundColor = Color.White private const val Tag = "Ripple" diff --git a/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/Ripple.android.kt b/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/Ripple.android.kt index 6c8b99c7be83b..0d42b78e544fd 100644 --- a/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/Ripple.android.kt +++ b/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/Ripple.android.kt @@ -131,14 +131,21 @@ internal class AndroidRippleNode( // currently drawn ripples if the ripples are being drawn on the RenderThread, // since only the software paint is updated, not the hardware paint used in // RippleForeground. - // Radius updates will not take effect until the next ripple, so if the size changes - // the only way to update the calculated radius is by using + + // For radius: + // - On R and below, updates will not take effect until the next ripple, so if the + // size changes the only way to update the calculated radius is by using // RippleDrawable.RADIUS_AUTO to calculate the radius from the bounds automatically. // But in this case, if the bounds change, the animation will switch to the UI // thread instead of render thread, so this isn't clearly desired either. // b/183019123 + // - On S and above, when hotspot bounds change mid-ripple, the radius / bounds / + // origin will be updated for the ongoing ripple, even for explicitly set radii. + // Note that for this to work the radius _must_ be set before we update bounds, as + // changing the radius on its own won't do anything. setRippleProperties( size = rippleSize, + radius = targetRadius.roundToInt(), color = rippleColor, alpha = rippleAlpha().pressedAlpha ) @@ -265,7 +272,12 @@ internal class AndroidRippleIndicationInstance( // currently drawn ripples if the ripples are being drawn on the RenderThread, // since only the software paint is updated, not the hardware paint used in // RippleForeground. - setRippleProperties(size = size, color = color, alpha = alpha) + setRippleProperties( + size = size, + radius = rippleRadius, + color = color, + alpha = alpha + ) draw(canvas.nativeCanvas) } diff --git a/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt b/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt index 24815c3a0e3a6..d1f3bf4158aad 100644 --- a/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt +++ b/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt @@ -131,8 +131,7 @@ internal class RippleHostView(context: Context) : View(context) { } val ripple = ripple!! this.onInvalidateRipple = onInvalidateRipple - ripple.trySetRadius(radius) - setRippleProperties(size, color, alpha) + setRippleProperties(size, radius, color, alpha) if (bounded) { // Bounded ripples should animate from the press position ripple.setHotspot(interaction.pressPosition.x, interaction.pressPosition.y) @@ -155,12 +154,16 @@ internal class RippleHostView(context: Context) : View(context) { } /** Update the underlying [RippleDrawable] with the new properties. */ - fun setRippleProperties(size: Size, color: Color, alpha: Float) { + fun setRippleProperties(size: Size, radius: Int, color: Color, alpha: Float) { val ripple = ripple ?: return // NOTE: if adding new properties here, make sure they are guarded with an equality check // (either here or internally in RippleDrawable). Many properties invalidate the ripple when // changed, which will lead to a call to updateRippleProperties again, which will cause // another invalidation, etc. + // Note: for cases where size and radius are updated during an existing ripple, the radius + // must be set first - changing the bounds is what causes the ripple to be updated, + // changing the radius on its own will not update the ripple. + ripple.trySetRadius(radius) ripple.setColor(color, alpha) val newBounds = Rect(0, 0, size.width.roundToInt(), size.height.roundToInt()) // Drawing the background causes the view to update the bounds of the drawable diff --git a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt index ea86a2e4ada2a..ebd6bc31a935f 100644 --- a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt +++ b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/Ripple.kt @@ -324,9 +324,7 @@ internal abstract class RippleNode( // The following are calculated inside onRemeasured(). These must be initialized before adding // a ripple. - // Target radius updating over time for existing ripples isn't supported for Android, and - // isn't implemented in common, so for now it can be private. - private var targetRadius: Float = 0f + protected var targetRadius: Float = 0f // The size is needed for Android to update ripple bounds if the size changes protected var rippleSize: Size = Size.Zero private set diff --git a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt index ec55c7ae72647..6393e804fc6eb 100644 --- a/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt +++ b/compose/material/material-ripple/src/commonMain/kotlin/androidx/compose/material/ripple/RippleTheme.kt @@ -67,7 +67,7 @@ public interface RippleTheme { "is not used by the material3 library. To remove confusion and link the " + "defaults more strongly to the design system library, these default values have " + "been moved to the material and material3 libraries. For material, use " + - "MaterialRippleThemeDefaults#rippleColor. For material3, use content color " + + "RippleDefaults#rippleColor. For material3, use content color " + "directly.", level = DeprecationLevel.WARNING ) @@ -97,8 +97,8 @@ public interface RippleTheme { "is not used by the material3 library. To remove confusion and link the " + "defaults more strongly to the design system library, these default values have " + "been moved to the material and material3 libraries. For material, use " + - "MaterialRippleThemeDefaults#rippleAlpha. For material3, use " + - "MaterialRippleThemeDefaults#RippleAlpha.", + "RippleDefaults#rippleAlpha. For material3, use " + + "RippleDefaults#RippleAlpha.", level = DeprecationLevel.WARNING ) public fun defaultRippleAlpha(contentColor: Color, lightTheme: Boolean): RippleAlpha { diff --git a/compose/material/material/api/current.ignore b/compose/material/material/api/current.ignore new file mode 100644 index 0000000000000..26d7abfb7b779 --- /dev/null +++ b/compose/material/material/api/current.ignore @@ -0,0 +1,3 @@ +// Baseline format: 1.0 +RemovedClass: androidx.compose.material.ExposedDropdownMenu_androidKt: + Removed class androidx.compose.material.ExposedDropdownMenu_androidKt diff --git a/compose/material/material/api/current.txt b/compose/material/material/api/current.txt index c7fe7607ddbbc..d4702af167d4a 100644 --- a/compose/material/material/api/current.txt +++ b/compose/material/material/api/current.txt @@ -215,6 +215,7 @@ package androidx.compose.material { property public final float IconSpacing; property public final float MinHeight; property public final float MinWidth; + property public static final float OutlinedBorderOpacity; property public final float OutlinedBorderSize; property public final androidx.compose.foundation.layout.PaddingValues TextButtonContentPadding; property @androidx.compose.runtime.Composable public final androidx.compose.foundation.BorderStroke outlinedBorder; @@ -269,8 +270,11 @@ package androidx.compose.material { method public float getSelectedIconSize(); method @androidx.compose.runtime.Composable public androidx.compose.material.ChipColors outlinedChipColors(optional long backgroundColor, optional long contentColor, optional long leadingIconContentColor, optional long disabledBackgroundColor, optional long disabledContentColor, optional long disabledLeadingIconContentColor); method @androidx.compose.runtime.Composable public androidx.compose.material.SelectableChipColors outlinedFilterChipColors(optional long backgroundColor, optional long contentColor, optional long leadingIconColor, optional long disabledBackgroundColor, optional long disabledContentColor, optional long disabledLeadingIconColor, optional long selectedBackgroundColor, optional long selectedContentColor, optional long selectedLeadingIconColor); + property public static final float ContentOpacity; + property public static final float LeadingIconOpacity; property public final float LeadingIconSize; property public final float MinHeight; + property public static final float OutlinedBorderOpacity; property public final float OutlinedBorderSize; property public final float SelectedIconSize; property @androidx.compose.runtime.Composable public final androidx.compose.foundation.BorderStroke outlinedBorder; @@ -381,6 +385,7 @@ package androidx.compose.material { method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getShape(); property public final androidx.compose.animation.core.TweenSpec AnimationSpec; property public final float Elevation; + property public static final float ScrimOpacity; property @androidx.compose.runtime.Composable public final long backgroundColor; property @androidx.compose.runtime.Composable public final long scrimColor; property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape shape; @@ -452,7 +457,7 @@ package androidx.compose.material { field public static final androidx.compose.material.ExposedDropdownMenuDefaults INSTANCE; } - public final class ExposedDropdownMenu_androidKt { + public final class ExposedDropdownMenuKt { method @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static void ExposedDropdownMenuBox(boolean expanded, kotlin.jvm.functions.Function1 onExpandedChange, optional androidx.compose.ui.Modifier modifier, kotlin.jvm.functions.Function1 content); } @@ -605,6 +610,7 @@ package androidx.compose.material { public final class ProgressIndicatorDefaults { method public androidx.compose.animation.core.SpringSpec getProgressAnimationSpec(); method public float getStrokeWidth(); + property public static final float IndicatorBackgroundOpacity; property public final androidx.compose.animation.core.SpringSpec ProgressAnimationSpec; property public final float StrokeWidth; field public static final androidx.compose.material.ProgressIndicatorDefaults INSTANCE; @@ -689,8 +695,8 @@ package androidx.compose.material { } public final class SecureTextFieldKt { - method @androidx.compose.runtime.Composable public static void OutlinedSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); - method @androidx.compose.runtime.Composable public static void SecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); + method @androidx.compose.runtime.Composable public static void OutlinedSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); + method @androidx.compose.runtime.Composable public static void SecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); } @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi public interface SelectableChipColors { @@ -719,6 +725,11 @@ package androidx.compose.material { public final class SliderDefaults { method @androidx.compose.runtime.Composable public androidx.compose.material.SliderColors colors(optional long thumbColor, optional long disabledThumbColor, optional long activeTrackColor, optional long inactiveTrackColor, optional long disabledActiveTrackColor, optional long disabledInactiveTrackColor, optional long activeTickColor, optional long inactiveTickColor, optional long disabledActiveTickColor, optional long disabledInactiveTickColor); + property public static final float DisabledActiveTrackAlpha; + property public static final float DisabledInactiveTrackAlpha; + property public static final float DisabledTickAlpha; + property public static final float InactiveTrackAlpha; + property public static final float TickAlpha; field public static final float DisabledActiveTrackAlpha = 0.32f; field public static final float DisabledInactiveTrackAlpha = 0.12f; field public static final float DisabledTickAlpha = 0.12f; @@ -805,6 +816,8 @@ package androidx.compose.material { method @Deprecated public float getVelocityThreshold(); method @Deprecated public androidx.compose.material.ResistanceConfig? resistanceConfig(java.util.Set anchors, optional float factorAtMin, optional float factorAtMax); property @Deprecated public final androidx.compose.animation.core.SpringSpec AnimationSpec; + property @Deprecated public static final float StandardResistanceFactor; + property @Deprecated public static final float StiffResistanceFactor; property @Deprecated public final float VelocityThreshold; field @Deprecated public static final androidx.compose.material.SwipeableDefaults INSTANCE; field @Deprecated public static final float StandardResistanceFactor = 10.0f; @@ -879,6 +892,7 @@ package androidx.compose.material { method public float getIndicatorHeight(); method public float getScrollableTabRowPadding(); method public androidx.compose.ui.Modifier tabIndicatorOffset(androidx.compose.ui.Modifier, androidx.compose.material.TabPosition currentTabPosition); + property public static final float DividerOpacity; property public final float DividerThickness; property public final float IndicatorHeight; property public final float ScrollableTabRowPadding; @@ -925,12 +939,15 @@ package androidx.compose.material { method @androidx.compose.runtime.Composable public androidx.compose.material.TextFieldColors textFieldColors(optional long textColor, optional long disabledTextColor, optional long backgroundColor, optional long cursorColor, optional long errorCursorColor, optional long focusedIndicatorColor, optional long unfocusedIndicatorColor, optional long disabledIndicatorColor, optional long errorIndicatorColor, optional long leadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long trailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor); method @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi public androidx.compose.foundation.layout.PaddingValues textFieldWithLabelPadding(optional float start, optional float end, optional float top, optional float bottom); method @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi public androidx.compose.foundation.layout.PaddingValues textFieldWithoutLabelPadding(optional float start, optional float top, optional float end, optional float bottom); + property public static final float BackgroundOpacity; property public final float FocusedBorderThickness; + property public static final float IconOpacity; property public final float MinHeight; property public final float MinWidth; property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final androidx.compose.ui.graphics.Shape OutlinedTextFieldShape; property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final androidx.compose.ui.graphics.Shape TextFieldShape; property public final float UnfocusedBorderThickness; + property public static final float UnfocusedIndicatorLineOpacity; field public static final float BackgroundOpacity = 0.12f; field public static final androidx.compose.material.TextFieldDefaults INSTANCE; field public static final float IconOpacity = 0.54f; diff --git a/compose/material/material/api/desktop/material.api b/compose/material/material/api/desktop/material.api index 28652cb25956e..47ad3ab87f857 100644 --- a/compose/material/material/api/desktop/material.api +++ b/compose/material/material/api/desktop/material.api @@ -513,7 +513,7 @@ public final class androidx/compose/material/ExposedDropdownMenuDefaults { public final fun textFieldColors-DlUQjxs (JJJJJJJJJJJJJJJJJJJJJJLandroidx/compose/runtime/Composer;IIII)Landroidx/compose/material/TextFieldColors; } -public final class androidx/compose/material/ExposedDropdownMenu_skikoKt { +public final class androidx/compose/material/ExposedDropdownMenuKt { public static final fun ExposedDropdownMenuBox (ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } @@ -770,8 +770,8 @@ public final class androidx/compose/material/ScaffoldState { } public final class androidx/compose/material/SecureTextFieldKt { - public static final fun OutlinedSecureTextField-kQQSZt8 (Landroidx/compose/foundation/text/input/TextFieldState;Landroidx/compose/ui/Modifier;ZZLandroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZLandroidx/compose/foundation/text/input/InputTransformation;ICLandroidx/compose/foundation/text/KeyboardOptions;Landroidx/compose/foundation/text/input/KeyboardActionHandler;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material/TextFieldColors;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;III)V - public static final fun SecureTextField-kQQSZt8 (Landroidx/compose/foundation/text/input/TextFieldState;Landroidx/compose/ui/Modifier;ZZLandroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZLandroidx/compose/foundation/text/input/InputTransformation;ICLandroidx/compose/foundation/text/KeyboardOptions;Landroidx/compose/foundation/text/input/KeyboardActionHandler;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material/TextFieldColors;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;III)V + public static final fun OutlinedSecureTextField-0vce7ms (Landroidx/compose/foundation/text/input/TextFieldState;Landroidx/compose/ui/Modifier;ZLandroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZLandroidx/compose/foundation/text/input/InputTransformation;ICLandroidx/compose/foundation/text/KeyboardOptions;Landroidx/compose/foundation/text/input/KeyboardActionHandler;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material/TextFieldColors;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;III)V + public static final fun SecureTextField-0vce7ms (Landroidx/compose/foundation/text/input/TextFieldState;Landroidx/compose/ui/Modifier;ZLandroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZLandroidx/compose/foundation/text/input/InputTransformation;ICLandroidx/compose/foundation/text/KeyboardOptions;Landroidx/compose/foundation/text/input/KeyboardActionHandler;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material/TextFieldColors;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;III)V } public abstract interface class androidx/compose/material/SelectableChipColors { diff --git a/compose/material/material/api/restricted_current.ignore b/compose/material/material/api/restricted_current.ignore new file mode 100644 index 0000000000000..26d7abfb7b779 --- /dev/null +++ b/compose/material/material/api/restricted_current.ignore @@ -0,0 +1,3 @@ +// Baseline format: 1.0 +RemovedClass: androidx.compose.material.ExposedDropdownMenu_androidKt: + Removed class androidx.compose.material.ExposedDropdownMenu_androidKt diff --git a/compose/material/material/api/restricted_current.txt b/compose/material/material/api/restricted_current.txt index c7fe7607ddbbc..d4702af167d4a 100644 --- a/compose/material/material/api/restricted_current.txt +++ b/compose/material/material/api/restricted_current.txt @@ -215,6 +215,7 @@ package androidx.compose.material { property public final float IconSpacing; property public final float MinHeight; property public final float MinWidth; + property public static final float OutlinedBorderOpacity; property public final float OutlinedBorderSize; property public final androidx.compose.foundation.layout.PaddingValues TextButtonContentPadding; property @androidx.compose.runtime.Composable public final androidx.compose.foundation.BorderStroke outlinedBorder; @@ -269,8 +270,11 @@ package androidx.compose.material { method public float getSelectedIconSize(); method @androidx.compose.runtime.Composable public androidx.compose.material.ChipColors outlinedChipColors(optional long backgroundColor, optional long contentColor, optional long leadingIconContentColor, optional long disabledBackgroundColor, optional long disabledContentColor, optional long disabledLeadingIconContentColor); method @androidx.compose.runtime.Composable public androidx.compose.material.SelectableChipColors outlinedFilterChipColors(optional long backgroundColor, optional long contentColor, optional long leadingIconColor, optional long disabledBackgroundColor, optional long disabledContentColor, optional long disabledLeadingIconColor, optional long selectedBackgroundColor, optional long selectedContentColor, optional long selectedLeadingIconColor); + property public static final float ContentOpacity; + property public static final float LeadingIconOpacity; property public final float LeadingIconSize; property public final float MinHeight; + property public static final float OutlinedBorderOpacity; property public final float OutlinedBorderSize; property public final float SelectedIconSize; property @androidx.compose.runtime.Composable public final androidx.compose.foundation.BorderStroke outlinedBorder; @@ -381,6 +385,7 @@ package androidx.compose.material { method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getShape(); property public final androidx.compose.animation.core.TweenSpec AnimationSpec; property public final float Elevation; + property public static final float ScrimOpacity; property @androidx.compose.runtime.Composable public final long backgroundColor; property @androidx.compose.runtime.Composable public final long scrimColor; property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape shape; @@ -452,7 +457,7 @@ package androidx.compose.material { field public static final androidx.compose.material.ExposedDropdownMenuDefaults INSTANCE; } - public final class ExposedDropdownMenu_androidKt { + public final class ExposedDropdownMenuKt { method @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static void ExposedDropdownMenuBox(boolean expanded, kotlin.jvm.functions.Function1 onExpandedChange, optional androidx.compose.ui.Modifier modifier, kotlin.jvm.functions.Function1 content); } @@ -605,6 +610,7 @@ package androidx.compose.material { public final class ProgressIndicatorDefaults { method public androidx.compose.animation.core.SpringSpec getProgressAnimationSpec(); method public float getStrokeWidth(); + property public static final float IndicatorBackgroundOpacity; property public final androidx.compose.animation.core.SpringSpec ProgressAnimationSpec; property public final float StrokeWidth; field public static final androidx.compose.material.ProgressIndicatorDefaults INSTANCE; @@ -689,8 +695,8 @@ package androidx.compose.material { } public final class SecureTextFieldKt { - method @androidx.compose.runtime.Composable public static void OutlinedSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); - method @androidx.compose.runtime.Composable public static void SecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); + method @androidx.compose.runtime.Composable public static void OutlinedSecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); + method @androidx.compose.runtime.Composable public static void SecureTextField(androidx.compose.foundation.text.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.text.TextStyle textStyle, optional kotlin.jvm.functions.Function0? label, optional kotlin.jvm.functions.Function0? placeholder, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? trailingIcon, optional boolean isError, optional androidx.compose.foundation.text.input.InputTransformation? inputTransformation, optional int textObfuscationMode, optional char textObfuscationCharacter, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.input.KeyboardActionHandler? onKeyboardAction, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material.TextFieldColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource); } @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi public interface SelectableChipColors { @@ -719,6 +725,11 @@ package androidx.compose.material { public final class SliderDefaults { method @androidx.compose.runtime.Composable public androidx.compose.material.SliderColors colors(optional long thumbColor, optional long disabledThumbColor, optional long activeTrackColor, optional long inactiveTrackColor, optional long disabledActiveTrackColor, optional long disabledInactiveTrackColor, optional long activeTickColor, optional long inactiveTickColor, optional long disabledActiveTickColor, optional long disabledInactiveTickColor); + property public static final float DisabledActiveTrackAlpha; + property public static final float DisabledInactiveTrackAlpha; + property public static final float DisabledTickAlpha; + property public static final float InactiveTrackAlpha; + property public static final float TickAlpha; field public static final float DisabledActiveTrackAlpha = 0.32f; field public static final float DisabledInactiveTrackAlpha = 0.12f; field public static final float DisabledTickAlpha = 0.12f; @@ -805,6 +816,8 @@ package androidx.compose.material { method @Deprecated public float getVelocityThreshold(); method @Deprecated public androidx.compose.material.ResistanceConfig? resistanceConfig(java.util.Set anchors, optional float factorAtMin, optional float factorAtMax); property @Deprecated public final androidx.compose.animation.core.SpringSpec AnimationSpec; + property @Deprecated public static final float StandardResistanceFactor; + property @Deprecated public static final float StiffResistanceFactor; property @Deprecated public final float VelocityThreshold; field @Deprecated public static final androidx.compose.material.SwipeableDefaults INSTANCE; field @Deprecated public static final float StandardResistanceFactor = 10.0f; @@ -879,6 +892,7 @@ package androidx.compose.material { method public float getIndicatorHeight(); method public float getScrollableTabRowPadding(); method public androidx.compose.ui.Modifier tabIndicatorOffset(androidx.compose.ui.Modifier, androidx.compose.material.TabPosition currentTabPosition); + property public static final float DividerOpacity; property public final float DividerThickness; property public final float IndicatorHeight; property public final float ScrollableTabRowPadding; @@ -925,12 +939,15 @@ package androidx.compose.material { method @androidx.compose.runtime.Composable public androidx.compose.material.TextFieldColors textFieldColors(optional long textColor, optional long disabledTextColor, optional long backgroundColor, optional long cursorColor, optional long errorCursorColor, optional long focusedIndicatorColor, optional long unfocusedIndicatorColor, optional long disabledIndicatorColor, optional long errorIndicatorColor, optional long leadingIconColor, optional long disabledLeadingIconColor, optional long errorLeadingIconColor, optional long trailingIconColor, optional long disabledTrailingIconColor, optional long errorTrailingIconColor, optional long focusedLabelColor, optional long unfocusedLabelColor, optional long disabledLabelColor, optional long errorLabelColor, optional long placeholderColor, optional long disabledPlaceholderColor); method @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi public androidx.compose.foundation.layout.PaddingValues textFieldWithLabelPadding(optional float start, optional float end, optional float top, optional float bottom); method @SuppressCompatibility @androidx.compose.material.ExperimentalMaterialApi public androidx.compose.foundation.layout.PaddingValues textFieldWithoutLabelPadding(optional float start, optional float top, optional float end, optional float bottom); + property public static final float BackgroundOpacity; property public final float FocusedBorderThickness; + property public static final float IconOpacity; property public final float MinHeight; property public final float MinWidth; property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final androidx.compose.ui.graphics.Shape OutlinedTextFieldShape; property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final androidx.compose.ui.graphics.Shape TextFieldShape; property public final float UnfocusedBorderThickness; + property public static final float UnfocusedIndicatorLineOpacity; field public static final float BackgroundOpacity = 0.12f; field public static final androidx.compose.material.TextFieldDefaults INSTANCE; field public static final float IconOpacity = 0.54f; diff --git a/compose/material/material/samples/src/main/java/androidx/compose/material/samples/SelectionControlsSamples.kt b/compose/material/material/samples/src/main/java/androidx/compose/material/samples/SelectionControlsSamples.kt index 626e168bc64a2..1a726c50c3b58 100644 --- a/compose/material/material/samples/src/main/java/androidx/compose/material/samples/SelectionControlsSamples.kt +++ b/compose/material/material/samples/src/main/java/androidx/compose/material/samples/SelectionControlsSamples.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -89,6 +90,7 @@ fun TriStateCheckboxSample() { } } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun FocusedCheckboxSample() { Text("The gray circle around this checkbox means it's non-touch focused. Press Tab to move it") diff --git a/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ObservableThemeTest.kt b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ObservableThemeTest.kt index 8b95a4d75be8b..f80891351acc3 100644 --- a/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ObservableThemeTest.kt +++ b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ObservableThemeTest.kt @@ -195,7 +195,7 @@ private fun ExpensiveSecondaryColorConsumer(compositionTracker: CompositionTrack * Immutable as we want to ensure that we always skip recomposition unless the CompositionLocal * value inside the function changes. */ -@Immutable private class CompositionTracker(var compositions: Int = 0) +@Immutable internal class CompositionTracker(var compositions: Int = 0) private val LocalTestTheme = staticCompositionLocalOf { error("CompositionLocal LocalTestThemem not present") } diff --git a/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ScaffoldTest.kt b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ScaffoldTest.kt index e9245e3b19c19..a3d4e13e2023f 100644 --- a/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ScaffoldTest.kt +++ b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/ScaffoldTest.kt @@ -17,6 +17,7 @@ package androidx.compose.material import android.os.Build +import androidx.activity.ComponentActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding @@ -34,6 +36,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.testutils.LayeredComposeTestCase +import androidx.compose.testutils.ToggleableTestCase +import androidx.compose.testutils.assertNoPendingChanges +import androidx.compose.testutils.doFramesUntilNoChangesPending +import androidx.compose.testutils.forGivenTestCase import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset @@ -53,7 +60,7 @@ import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertWidthIsEqualTo import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeLeft @@ -61,6 +68,7 @@ import androidx.compose.ui.test.swipeRight import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import androidx.compose.ui.zIndex @@ -71,6 +79,7 @@ import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlin.math.roundToInt import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Ignore import org.junit.Rule import org.junit.Test @@ -80,7 +89,7 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ScaffoldTest { - @get:Rule val rule = createComposeRule() + @get:Rule val rule = createAndroidComposeRule() private val fabSpacing = 16.dp private val scaffoldTag = "Scaffold" @@ -719,9 +728,68 @@ class ScaffoldTest { assertWithMessage("Expected placeCount to be >= 1").that(onPlaceCount).isAtLeast(1) } + // Regression test for b/373904168 + @Test + fun scaffold_topBarHeightChanging_noRecompositionInBody() { + val testCase = TopBarHeightChangingScaffoldTestCase() + rule.forGivenTestCase(testCase).performTestWithEventsControl { + doFrame() + assertNoPendingChanges() + + assertEquals(1, testCase.tracker.compositions) + + testCase.toggleState() + + doFramesUntilNoChangesPending(maxAmountOfFrames = 1) + + assertEquals(1, testCase.tracker.compositions) + } + } + private fun assertDpIsWithinThreshold(actual: Dp, expected: Dp, threshold: Dp) { assertThat(actual.value).isWithin(threshold.value).of(expected.value) } private val roundingError = 0.5.dp } + +private class TopBarHeightChangingScaffoldTestCase : LayeredComposeTestCase(), ToggleableTestCase { + + private lateinit var state: MutableState + + val tracker = CompositionTracker() + + @Composable + override fun MeasuredContent() { + state = remember { mutableStateOf(0.dp) } + val paddingValues = remember { + object : PaddingValues { + override fun calculateBottomPadding(): Dp = state.value + + override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp + + override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp + + override fun calculateTopPadding(): Dp = 0.dp + } + } + + Scaffold( + topBar = { + TopAppBar(title = { Text("Title") }, modifier = Modifier.padding(paddingValues)) + }, + ) { contentPadding -> + tracker.compositions++ + Box(Modifier.padding(contentPadding).fillMaxSize()) + } + } + + @Composable + override fun ContentWrappers(content: @Composable () -> Unit) { + MaterialTheme { content() } + } + + override fun toggleState() { + state.value = if (state.value == 0.dp) 10.dp else 0.dp + } +} diff --git a/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/SlideUsingKeysTest.kt b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/SlideUsingKeysTest.kt new file mode 100644 index 0000000000000..d4a5f00cab991 --- /dev/null +++ b/compose/material/material/src/androidInstrumentedTest/kotlin/androidx/compose/material/SlideUsingKeysTest.kt @@ -0,0 +1,269 @@ +/* + * 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. + */ + +package androidx.compose.material + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.NativeKeyEvent +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyPress +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.LayoutDirection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import kotlin.math.roundToInt +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalTestApi::class) +@MediumTest +@RunWith(AndroidJUnit4::class) +class SlideUsingKeysTest { + + @Test + fun slider_ltr_0steps_change_using_keys() = runComposeUiTest { + val state = mutableStateOf(0.5f) + var sliderFocused = false + + setContent { + Slider( + value = state.value, + onValueChange = { state.value = it }, + valueRange = 0f..1f, + modifier = Modifier.onFocusChanged { sliderFocused = it.isFocused } + ) + } + + // Press tab to focus on Slider + onRoot().performKeyPress(KeyEvent(Key.Tab, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.Tab, KeyEventType.KeyUp)) + runOnIdle { assertTrue(sliderFocused) } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionRight, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionRight, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.50f + (1 + it) / 100f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionLeft, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionLeft, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.53f - (1 + it) / 100f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.PageDown, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.PageDown, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.50f + (1 + it) / 10f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.PageUp, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.PageUp, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.80f - (1 + it) / 10f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionUp, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionUp, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.50f + (1 + it) / 100f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionDown, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionDown, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.53f - (1 + it) / 100f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + + onRoot().performKeyPress(KeyEvent(Key.MoveEnd, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.MoveEnd, KeyEventType.KeyUp)) + runOnIdle { assertEquals(1f, state.value) } + + onRoot().performKeyPress(KeyEvent(Key.MoveHome, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.MoveHome, KeyEventType.KeyUp)) + runOnIdle { assertEquals(0f, state.value) } + } + + @Test + fun slider_rtl_0steps_change_using_keys() = runComposeUiTest { + val state = mutableStateOf(0.5f) + var sliderFocused = false + setContent { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Slider( + value = state.value, + onValueChange = { state.value = it }, + valueRange = 0f..1f, + modifier = Modifier.onFocusChanged { sliderFocused = it.isFocused } + ) + } + } + + // Press tab to focus on Slider + onRoot().performKeyPress(KeyEvent(Key.Tab, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.Tab, KeyEventType.KeyUp)) + runOnIdle { assertTrue(sliderFocused) } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionRight, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionRight, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.50f - (1 + it) / 100f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionLeft, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionLeft, KeyEventType.KeyUp)) + runOnIdle { + assertEquals( + (0.47f + (1 + it) / 100f).round2decPlaces(), + (state.value).round2decPlaces() + ) + } + } + } + + @Test + fun slider_ltr_29steps_using_keys() = runComposeUiTest { + val state = mutableStateOf(15f) + var sliderFocused = false + setContent { + Slider( + value = state.value, + steps = 29, + onValueChange = { state.value = it }, + valueRange = 0f..30f, + modifier = Modifier.onFocusChanged { sliderFocused = it.isFocused } + ) + } + + // Press tab to focus on Slider + onRoot().performKeyPress(KeyEvent(Key.Tab, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.Tab, KeyEventType.KeyUp)) + runOnIdle { assertTrue(sliderFocused) } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionRight, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionRight, KeyEventType.KeyUp)) + runOnIdle { assertEquals((15f + (1f + it)), (state.value)) } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionLeft, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionLeft, KeyEventType.KeyUp)) + runOnIdle { assertEquals((18f - (1 + it)), state.value) } + } + + runOnIdle { state.value = 0f } + + val page = ((29 + 1) / 10).coerceIn(1, 10) // same logic as in Slider slideOnKeyEvents + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.PageDown, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.PageDown, KeyEventType.KeyUp)) + runOnIdle { assertEquals((1f + it) * page, state.value) } + } + + runOnIdle { state.value = 30f } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.PageUp, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.PageUp, KeyEventType.KeyUp)) + runOnIdle { assertEquals(30f - (1 + it) * page, state.value) } + } + + runOnIdle { state.value = 0f } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionUp, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionUp, KeyEventType.KeyUp)) + runOnIdle { assertEquals(1f + it, state.value) } + } + + repeat(3) { + onRoot().performKeyPress(KeyEvent(Key.DirectionDown, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.DirectionDown, KeyEventType.KeyUp)) + runOnIdle { assertEquals(3f - (1f + it), state.value) } + } + + onRoot().performKeyPress(KeyEvent(Key.MoveEnd, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.MoveEnd, KeyEventType.KeyUp)) + runOnIdle { assertEquals(30f, state.value) } + + onRoot().performKeyPress(KeyEvent(Key.MoveHome, KeyEventType.KeyDown)) + onRoot().performKeyPress(KeyEvent(Key.MoveHome, KeyEventType.KeyUp)) + runOnIdle { assertEquals(0f, state.value) } + } +} + +private fun KeyEventType.toNativeAction(): Int { + return when (this) { + KeyEventType.KeyUp -> NativeKeyEvent.ACTION_UP + KeyEventType.KeyDown -> NativeKeyEvent.ACTION_DOWN + else -> error("KeyEventType - $this") + } +} + +private fun KeyEvent(key: Key, type: KeyEventType): KeyEvent { + return KeyEvent(NativeKeyEvent(type.toNativeAction(), key.nativeKeyCode)) +} + +private fun Float.round2decPlaces() = (this * 100).roundToInt() / 100f diff --git a/compose/material/material/src/androidMain/kotlin/androidx/compose/material/ExposedDropdownMenu.android.kt b/compose/material/material/src/androidMain/kotlin/androidx/compose/material/ExposedDropdownMenu.android.kt index 08c455f3cc4c1..69855e799347e 100644 --- a/compose/material/material/src/androidMain/kotlin/androidx/compose/material/ExposedDropdownMenu.android.kt +++ b/compose/material/material/src/androidMain/kotlin/androidx/compose/material/ExposedDropdownMenu.android.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * 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. @@ -13,98 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package androidx.compose.material -import android.graphics.Rect +import android.graphics.Rect as ViewRect import android.view.View import android.view.ViewTreeObserver -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.width -import androidx.compose.material.internal.ExposedDropdownMenuPopup import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.toComposeIntRect import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.DpOffset -import kotlin.math.max +import androidx.compose.ui.unit.IntRect + +internal actual class WindowBoundsCalculator(private val view: View) { + actual fun getVisibleWindowBounds(): IntRect = view.getWindowBounds() +} -@ExperimentalMaterialApi @Composable -actual fun ExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable ExposedDropdownMenuBoxScope.() -> Unit -) { - val density = LocalDensity.current +internal actual fun platformWindowBoundsCalculator(): WindowBoundsCalculator { val view = LocalView.current - var width by remember { mutableIntStateOf(0) } - var menuHeight by remember { mutableIntStateOf(0) } - val verticalMarginInPx = with(density) { MenuVerticalMargin.roundToPx() } - val coordinates = remember { Ref() } - - val scope = - remember(density, menuHeight, width) { - object : ExposedDropdownMenuBoxScope() { - override fun Modifier.exposedDropdownSize(matchTextFieldWidth: Boolean): Modifier { - return with(density) { - heightIn(max = menuHeight.toDp()).let { - if (matchTextFieldWidth) { - it.width(width.toDp()) - } else it - } - } - } - } - } - val focusRequester = remember { FocusRequester() } - - Box( - modifier - .onGloballyPositioned { - width = it.size.width - coordinates.value = it - updateHeight(view.rootView, coordinates.value, verticalMarginInPx) { newHeight -> - menuHeight = newHeight - } - } - .expandable( - onExpandedChange = { onExpandedChange(!expanded) }, - menuLabel = getString(Strings.ExposedDropdownMenu) - ) - .focusRequester(focusRequester) - ) { - scope.content() - } - - SideEffect { if (expanded) focusRequester.requestFocus() } + return remember(view) { WindowBoundsCalculator(view) } +} +@Composable +internal actual fun OnPlatformWindowBoundsChange(block: () -> Unit) { + val view = LocalView.current DisposableEffect(view) { - val listener = - OnGlobalLayoutListener(view) { - // We want to recalculate the menu height on relayout - e.g. when keyboard shows up. - updateHeight(view.rootView, coordinates.value, verticalMarginInPx) { newHeight -> - menuHeight = newHeight - } - } + val listener = OnGlobalLayoutListener(view, block) onDispose { listener.dispose() } } } @@ -148,62 +84,8 @@ private class OnGlobalLayoutListener( } } -internal actual fun ExposedDropdownMenuBoxScope.ExposedDropdownMenuDefaultImpl( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - scrollState: ScrollState, - content: @Composable ColumnScope.() -> Unit -) { - // TODO(b/202810604): use DropdownMenu when PopupProperties constructor is stable - // return DropdownMenu( - // expanded = expanded, - // onDismissRequest = onDismissRequest, - // modifier = modifier.exposedDropdownSize(), - // properties = ExposedDropdownMenuDefaults.PopupProperties, - // content = content - // ) - - val expandedStates = remember { MutableTransitionState(false) } - expandedStates.targetState = expanded - - if (expandedStates.currentState || expandedStates.targetState) { - val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } - val density = LocalDensity.current - val popupPositionProvider = - DropdownMenuPositionProvider(DpOffset.Zero, density) { parentBounds, menuBounds -> - transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds) - } - - ExposedDropdownMenuPopup( - onDismissRequest = onDismissRequest, - popupPositionProvider = popupPositionProvider - ) { - DropdownMenuContent( - expandedStates = expandedStates, - transformOriginState = transformOriginState, - scrollState = scrollState, - modifier = modifier.exposedDropdownSize(), - content = content - ) - } +private fun View.getWindowBounds(): IntRect = + ViewRect().let { + this.getWindowVisibleDisplayFrame(it) + it.toComposeIntRect() } -} - -private fun updateHeight( - view: View, - coordinates: LayoutCoordinates?, - verticalMarginInPx: Int, - onHeightUpdate: (Int) -> Unit -) { - coordinates ?: return - val visibleWindowBounds = - Rect().let { - view.getWindowVisibleDisplayFrame(it) - it - } - val heightAbove = coordinates.boundsInWindow().top - visibleWindowBounds.top - val heightBelow = - visibleWindowBounds.bottom - visibleWindowBounds.top - coordinates.boundsInWindow().bottom - onHeightUpdate(max(heightAbove, heightBelow).toInt() - verticalMarginInPx) -} \ No newline at end of file diff --git a/compose/material/material/src/androidMain/kotlin/androidx/compose/material/Strings.android.kt b/compose/material/material/src/androidMain/kotlin/androidx/compose/material/Strings.android.kt index 4ce4a0e9e1e07..cde087f7a20eb 100644 --- a/compose/material/material/src/androidMain/kotlin/androidx/compose/material/Strings.android.kt +++ b/compose/material/material/src/androidMain/kotlin/androidx/compose/material/Strings.android.kt @@ -16,6 +16,7 @@ package androidx.compose.material +import androidx.compose.material.R as MaterialR import androidx.compose.runtime.Composable import androidx.compose.ui.R import androidx.compose.ui.platform.LocalConfiguration @@ -33,7 +34,7 @@ internal actual fun getString(string: Strings): String { Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu) Strings.SliderRangeStart -> resources.getString(R.string.range_start) Strings.SliderRangeEnd -> resources.getString(R.string.range_end) - Strings.SnackbarPaneTitle -> resources.getString(R.string.snackbar_pane_title) + Strings.SnackbarPaneTitle -> resources.getString(MaterialR.string.mc2_snackbar_pane_title) else -> "" } } diff --git a/compose/material/material/src/androidMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.android.kt b/compose/material/material/src/androidMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.android.kt index ecbd45c1b42c7..a7e7b4061aeb9 100644 --- a/compose/material/material/src/androidMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.android.kt +++ b/compose/material/material/src/androidMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.android.kt @@ -73,12 +73,9 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner import java.util.UUID import kotlin.math.roundToInt -/** - * Popup specific for exposed dropdown menus. b/202810604. Should not be used in other components. - */ @Composable -internal fun ExposedDropdownMenuPopup( - onDismissRequest: (() -> Unit)? = null, +internal actual fun ExposedDropdownMenuPopup( + onDismissRequest: (() -> Unit)?, popupPositionProvider: PopupPositionProvider, content: @Composable () -> Unit ) { diff --git a/compose/material/material/src/androidMain/res/values-af/strings.xml b/compose/material/material/src/androidMain/res/values-af/strings.xml new file mode 100644 index 0000000000000..eede0879d15f5 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-af/strings.xml @@ -0,0 +1,21 @@ + + + + + "Opletberig" + diff --git a/compose/material/material/src/androidMain/res/values-am/strings.xml b/compose/material/material/src/androidMain/res/values-am/strings.xml new file mode 100644 index 0000000000000..ec8e8e79fb91d --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-am/strings.xml @@ -0,0 +1,21 @@ + + + + + "ማንቂያ" + diff --git a/compose/material/material/src/androidMain/res/values-ar/strings.xml b/compose/material/material/src/androidMain/res/values-ar/strings.xml new file mode 100644 index 0000000000000..4db049a678971 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ar/strings.xml @@ -0,0 +1,21 @@ + + + + + "تنبيه" + diff --git a/compose/material/material/src/androidMain/res/values-as/strings.xml b/compose/material/material/src/androidMain/res/values-as/strings.xml new file mode 100644 index 0000000000000..c5a47f6e3fae4 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-as/strings.xml @@ -0,0 +1,21 @@ + + + + + "সতৰ্কবাৰ্তা" + diff --git a/compose/material/material/src/androidMain/res/values-az/strings.xml b/compose/material/material/src/androidMain/res/values-az/strings.xml new file mode 100644 index 0000000000000..00182391aff1e --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-az/strings.xml @@ -0,0 +1,21 @@ + + + + + "Xəbərdarlıq" + diff --git a/compose/material/material/src/androidMain/res/values-b+sr+Latn/strings.xml b/compose/material/material/src/androidMain/res/values-b+sr+Latn/strings.xml new file mode 100644 index 0000000000000..bc20a6898216b --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-b+sr+Latn/strings.xml @@ -0,0 +1,21 @@ + + + + + "Obaveštenje" + diff --git a/compose/material/material/src/androidMain/res/values-be/strings.xml b/compose/material/material/src/androidMain/res/values-be/strings.xml new file mode 100644 index 0000000000000..561925bfa1921 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-be/strings.xml @@ -0,0 +1,21 @@ + + + + + "Абвестка" + diff --git a/compose/material/material/src/androidMain/res/values-bg/strings.xml b/compose/material/material/src/androidMain/res/values-bg/strings.xml new file mode 100644 index 0000000000000..413470754759b --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-bg/strings.xml @@ -0,0 +1,21 @@ + + + + + "Сигнал" + diff --git a/compose/material/material/src/androidMain/res/values-bn/strings.xml b/compose/material/material/src/androidMain/res/values-bn/strings.xml new file mode 100644 index 0000000000000..79689015750ce --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-bn/strings.xml @@ -0,0 +1,21 @@ + + + + + "সতর্কতা" + diff --git a/compose/material/material/src/androidMain/res/values-bs/strings.xml b/compose/material/material/src/androidMain/res/values-bs/strings.xml new file mode 100644 index 0000000000000..d7fc77eef49ae --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-bs/strings.xml @@ -0,0 +1,21 @@ + + + + + "Obavještenje" + diff --git a/compose/material/material/src/androidMain/res/values-ca/strings.xml b/compose/material/material/src/androidMain/res/values-ca/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ca/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-cs/strings.xml b/compose/material/material/src/androidMain/res/values-cs/strings.xml new file mode 100644 index 0000000000000..9e57e65ba0ca1 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-cs/strings.xml @@ -0,0 +1,21 @@ + + + + + "Upozornění" + diff --git a/compose/material/material/src/androidMain/res/values-da/strings.xml b/compose/material/material/src/androidMain/res/values-da/strings.xml new file mode 100644 index 0000000000000..3fe0251601ffb --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-da/strings.xml @@ -0,0 +1,21 @@ + + + + + "Underretning" + diff --git a/compose/material/material/src/androidMain/res/values-de/strings.xml b/compose/material/material/src/androidMain/res/values-de/strings.xml new file mode 100644 index 0000000000000..c0ea84d74a90e --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-de/strings.xml @@ -0,0 +1,21 @@ + + + + + "Warnmeldung" + diff --git a/compose/material/material/src/androidMain/res/values-el/strings.xml b/compose/material/material/src/androidMain/res/values-el/strings.xml new file mode 100644 index 0000000000000..4971504e9c7c4 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-el/strings.xml @@ -0,0 +1,21 @@ + + + + + "Ειδοποίηση" + diff --git a/compose/material/material/src/androidMain/res/values-en-rAU/strings.xml b/compose/material/material/src/androidMain/res/values-en-rAU/strings.xml new file mode 100644 index 0000000000000..593990bbe0d7c --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-en-rAU/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alert" + diff --git a/compose/material/material/src/androidMain/res/values-en-rCA/strings.xml b/compose/material/material/src/androidMain/res/values-en-rCA/strings.xml new file mode 100644 index 0000000000000..593990bbe0d7c --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-en-rCA/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alert" + diff --git a/compose/material/material/src/androidMain/res/values-en-rGB/strings.xml b/compose/material/material/src/androidMain/res/values-en-rGB/strings.xml new file mode 100644 index 0000000000000..593990bbe0d7c --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-en-rGB/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alert" + diff --git a/compose/material/material/src/androidMain/res/values-en-rIN/strings.xml b/compose/material/material/src/androidMain/res/values-en-rIN/strings.xml new file mode 100644 index 0000000000000..593990bbe0d7c --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-en-rIN/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alert" + diff --git a/compose/material/material/src/androidMain/res/values-en-rXC/strings.xml b/compose/material/material/src/androidMain/res/values-en-rXC/strings.xml new file mode 100644 index 0000000000000..9c6c2901054bb --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-en-rXC/strings.xml @@ -0,0 +1,21 @@ + + + + + "‎‏‎‎‎‎‎‏‎‏‏‏‎‎‎‎‎‎‏‎‎‏‎‎‎‎‏‏‏‏‏‏‎‏‎‎‏‏‎‏‎‏‏‏‏‏‏‏‏‏‏‏‏‎‎‏‎‏‏‎‎‏‎‎‏‏‏‎‎‏‎‏‏‏‎‎‎‎‎‏‎‎‏‏‎‎‏‏‏‎‎‏‎‎‎‎Alert‎‏‎‎‏‎" + diff --git a/compose/material/material/src/androidMain/res/values-es-rUS/strings.xml b/compose/material/material/src/androidMain/res/values-es-rUS/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-es-rUS/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-es/strings.xml b/compose/material/material/src/androidMain/res/values-es/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-es/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-et/strings.xml b/compose/material/material/src/androidMain/res/values-et/strings.xml new file mode 100644 index 0000000000000..b266a5209250d --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-et/strings.xml @@ -0,0 +1,21 @@ + + + + + "Hoiatus" + diff --git a/compose/material/material/src/androidMain/res/values-eu/strings.xml b/compose/material/material/src/androidMain/res/values-eu/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-eu/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-fa/strings.xml b/compose/material/material/src/androidMain/res/values-fa/strings.xml new file mode 100644 index 0000000000000..090a97784e88e --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-fa/strings.xml @@ -0,0 +1,21 @@ + + + + + "هشدار" + diff --git a/compose/material/material/src/androidMain/res/values-fi/strings.xml b/compose/material/material/src/androidMain/res/values-fi/strings.xml new file mode 100644 index 0000000000000..2a27a708475e8 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-fi/strings.xml @@ -0,0 +1,21 @@ + + + + + "Ilmoitus" + diff --git a/compose/material/material/src/androidMain/res/values-fr-rCA/strings.xml b/compose/material/material/src/androidMain/res/values-fr-rCA/strings.xml new file mode 100644 index 0000000000000..92585868dad9b --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-fr-rCA/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerte" + diff --git a/compose/material/material/src/androidMain/res/values-fr/strings.xml b/compose/material/material/src/androidMain/res/values-fr/strings.xml new file mode 100644 index 0000000000000..92585868dad9b --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-fr/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerte" + diff --git a/compose/material/material/src/androidMain/res/values-gl/strings.xml b/compose/material/material/src/androidMain/res/values-gl/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-gl/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-gu/strings.xml b/compose/material/material/src/androidMain/res/values-gu/strings.xml new file mode 100644 index 0000000000000..f10fd0a7556f3 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-gu/strings.xml @@ -0,0 +1,21 @@ + + + + + "અલર્ટ" + diff --git a/compose/material/material/src/androidMain/res/values-hi/strings.xml b/compose/material/material/src/androidMain/res/values-hi/strings.xml new file mode 100644 index 0000000000000..4d36439511acd --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-hi/strings.xml @@ -0,0 +1,21 @@ + + + + + "चेतावनी" + diff --git a/compose/material/material/src/androidMain/res/values-hr/strings.xml b/compose/material/material/src/androidMain/res/values-hr/strings.xml new file mode 100644 index 0000000000000..6932e148c1d09 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-hr/strings.xml @@ -0,0 +1,21 @@ + + + + + "Upozorenje" + diff --git a/compose/material/material/src/androidMain/res/values-hu/strings.xml b/compose/material/material/src/androidMain/res/values-hu/strings.xml new file mode 100644 index 0000000000000..5c340cc340796 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-hu/strings.xml @@ -0,0 +1,21 @@ + + + + + "Figyelmeztetés" + diff --git a/compose/material/material/src/androidMain/res/values-hy/strings.xml b/compose/material/material/src/androidMain/res/values-hy/strings.xml new file mode 100644 index 0000000000000..ddefc836699cd --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-hy/strings.xml @@ -0,0 +1,21 @@ + + + + + "Ծանուցում" + diff --git a/compose/material/material/src/androidMain/res/values-in/strings.xml b/compose/material/material/src/androidMain/res/values-in/strings.xml new file mode 100644 index 0000000000000..0cf4690772923 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-in/strings.xml @@ -0,0 +1,21 @@ + + + + + "Pemberitahuan" + diff --git a/compose/material/material/src/androidMain/res/values-is/strings.xml b/compose/material/material/src/androidMain/res/values-is/strings.xml new file mode 100644 index 0000000000000..1f62303ee7cb8 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-is/strings.xml @@ -0,0 +1,21 @@ + + + + + "Viðvörun" + diff --git a/compose/material/material/src/androidMain/res/values-it/strings.xml b/compose/material/material/src/androidMain/res/values-it/strings.xml new file mode 100644 index 0000000000000..70cafc001d296 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-it/strings.xml @@ -0,0 +1,21 @@ + + + + + "Avviso" + diff --git a/compose/material/material/src/androidMain/res/values-iw/strings.xml b/compose/material/material/src/androidMain/res/values-iw/strings.xml new file mode 100644 index 0000000000000..5b67aea27ba99 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-iw/strings.xml @@ -0,0 +1,21 @@ + + + + + "התראה" + diff --git a/compose/material/material/src/androidMain/res/values-ja/strings.xml b/compose/material/material/src/androidMain/res/values-ja/strings.xml new file mode 100644 index 0000000000000..10085f624f149 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ja/strings.xml @@ -0,0 +1,21 @@ + + + + + "アラート" + diff --git a/compose/material/material/src/androidMain/res/values-ka/strings.xml b/compose/material/material/src/androidMain/res/values-ka/strings.xml new file mode 100644 index 0000000000000..427c3753f5dd4 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ka/strings.xml @@ -0,0 +1,21 @@ + + + + + "გაფრთხილება" + diff --git a/compose/material/material/src/androidMain/res/values-kk/strings.xml b/compose/material/material/src/androidMain/res/values-kk/strings.xml new file mode 100644 index 0000000000000..ee437b9234d31 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-kk/strings.xml @@ -0,0 +1,21 @@ + + + + + "Ескерту" + diff --git a/compose/material/material/src/androidMain/res/values-km/strings.xml b/compose/material/material/src/androidMain/res/values-km/strings.xml new file mode 100644 index 0000000000000..77a002a1b92d0 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-km/strings.xml @@ -0,0 +1,21 @@ + + + + + "ជូន​ដំណឹង" + diff --git a/compose/material/material/src/androidMain/res/values-kn/strings.xml b/compose/material/material/src/androidMain/res/values-kn/strings.xml new file mode 100644 index 0000000000000..203a2fdcb2721 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-kn/strings.xml @@ -0,0 +1,21 @@ + + + + + "ಎಚ್ಚರಿಕೆ" + diff --git a/compose/material/material/src/androidMain/res/values-ko/strings.xml b/compose/material/material/src/androidMain/res/values-ko/strings.xml new file mode 100644 index 0000000000000..bd259b1d7e04b --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ko/strings.xml @@ -0,0 +1,21 @@ + + + + + "주의" + diff --git a/compose/material/material/src/androidMain/res/values-ky/strings.xml b/compose/material/material/src/androidMain/res/values-ky/strings.xml new file mode 100644 index 0000000000000..0e0c0750953c4 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ky/strings.xml @@ -0,0 +1,21 @@ + + + + + "Эскертүү" + diff --git a/compose/material/material/src/androidMain/res/values-lo/strings.xml b/compose/material/material/src/androidMain/res/values-lo/strings.xml new file mode 100644 index 0000000000000..02341ad76cd9d --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-lo/strings.xml @@ -0,0 +1,21 @@ + + + + + "ແຈ້ງເຕືອນ" + diff --git a/compose/material/material/src/androidMain/res/values-lt/strings.xml b/compose/material/material/src/androidMain/res/values-lt/strings.xml new file mode 100644 index 0000000000000..2376e719d7314 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-lt/strings.xml @@ -0,0 +1,21 @@ + + + + + "Įspėjimas" + diff --git a/compose/material/material/src/androidMain/res/values-lv/strings.xml b/compose/material/material/src/androidMain/res/values-lv/strings.xml new file mode 100644 index 0000000000000..824500c481d4d --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-lv/strings.xml @@ -0,0 +1,21 @@ + + + + + "Brīdinājums" + diff --git a/compose/material/material/src/androidMain/res/values-mk/strings.xml b/compose/material/material/src/androidMain/res/values-mk/strings.xml new file mode 100644 index 0000000000000..bf6f4e7913b0b --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-mk/strings.xml @@ -0,0 +1,21 @@ + + + + + "Предупредување" + diff --git a/compose/material/material/src/androidMain/res/values-ml/strings.xml b/compose/material/material/src/androidMain/res/values-ml/strings.xml new file mode 100644 index 0000000000000..0fbc9058aa3b4 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ml/strings.xml @@ -0,0 +1,21 @@ + + + + + "മുന്നറിയിപ്പ്" + diff --git a/compose/material/material/src/androidMain/res/values-mn/strings.xml b/compose/material/material/src/androidMain/res/values-mn/strings.xml new file mode 100644 index 0000000000000..349fbd976f1bb --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-mn/strings.xml @@ -0,0 +1,21 @@ + + + + + "Сэрэмжлүүлэг" + diff --git a/compose/material/material/src/androidMain/res/values-mr/strings.xml b/compose/material/material/src/androidMain/res/values-mr/strings.xml new file mode 100644 index 0000000000000..e0cbcf49d5666 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-mr/strings.xml @@ -0,0 +1,21 @@ + + + + + "सूचना" + diff --git a/compose/material/material/src/androidMain/res/values-ms/strings.xml b/compose/material/material/src/androidMain/res/values-ms/strings.xml new file mode 100644 index 0000000000000..0981ad0bde9c5 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ms/strings.xml @@ -0,0 +1,21 @@ + + + + + "Makluman" + diff --git a/compose/material/material/src/androidMain/res/values-my/strings.xml b/compose/material/material/src/androidMain/res/values-my/strings.xml new file mode 100644 index 0000000000000..0c202e351f7e8 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-my/strings.xml @@ -0,0 +1,21 @@ + + + + + "သတိပေးချက်" + diff --git a/compose/material/material/src/androidMain/res/values-nb/strings.xml b/compose/material/material/src/androidMain/res/values-nb/strings.xml new file mode 100644 index 0000000000000..bc0d3fc806b69 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-nb/strings.xml @@ -0,0 +1,21 @@ + + + + + "Varsel" + diff --git a/compose/material/material/src/androidMain/res/values-ne/strings.xml b/compose/material/material/src/androidMain/res/values-ne/strings.xml new file mode 100644 index 0000000000000..4ab15caf30413 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ne/strings.xml @@ -0,0 +1,21 @@ + + + + + "अलर्ट" + diff --git a/compose/material/material/src/androidMain/res/values-nl/strings.xml b/compose/material/material/src/androidMain/res/values-nl/strings.xml new file mode 100644 index 0000000000000..4becfb8581d9f --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-nl/strings.xml @@ -0,0 +1,21 @@ + + + + + "Melding" + diff --git a/compose/material/material/src/androidMain/res/values-or/strings.xml b/compose/material/material/src/androidMain/res/values-or/strings.xml new file mode 100644 index 0000000000000..c172b1c84e1cf --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-or/strings.xml @@ -0,0 +1,21 @@ + + + + + "ଆଲର୍ଟ" + diff --git a/compose/material/material/src/androidMain/res/values-pa/strings.xml b/compose/material/material/src/androidMain/res/values-pa/strings.xml new file mode 100644 index 0000000000000..7c0941e1c55c9 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-pa/strings.xml @@ -0,0 +1,21 @@ + + + + + "ਅਲਰਟ" + diff --git a/compose/material/material/src/androidMain/res/values-pl/strings.xml b/compose/material/material/src/androidMain/res/values-pl/strings.xml new file mode 100644 index 0000000000000..593990bbe0d7c --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-pl/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alert" + diff --git a/compose/material/material/src/androidMain/res/values-pt-rBR/strings.xml b/compose/material/material/src/androidMain/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-pt-rBR/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-pt-rPT/strings.xml b/compose/material/material/src/androidMain/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-pt-rPT/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-pt/strings.xml b/compose/material/material/src/androidMain/res/values-pt/strings.xml new file mode 100644 index 0000000000000..a6f7596754879 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-pt/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerta" + diff --git a/compose/material/material/src/androidMain/res/values-ro/strings.xml b/compose/material/material/src/androidMain/res/values-ro/strings.xml new file mode 100644 index 0000000000000..5289b85b20532 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ro/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alertă" + diff --git a/compose/material/material/src/androidMain/res/values-ru/strings.xml b/compose/material/material/src/androidMain/res/values-ru/strings.xml new file mode 100644 index 0000000000000..ee37a75b316a3 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ru/strings.xml @@ -0,0 +1,21 @@ + + + + + "Оповещение" + diff --git a/compose/material/material/src/androidMain/res/values-si/strings.xml b/compose/material/material/src/androidMain/res/values-si/strings.xml new file mode 100644 index 0000000000000..4c32b49d95ec8 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-si/strings.xml @@ -0,0 +1,21 @@ + + + + + "ඇඟවීම" + diff --git a/compose/material/material/src/androidMain/res/values-sk/strings.xml b/compose/material/material/src/androidMain/res/values-sk/strings.xml new file mode 100644 index 0000000000000..4d6ad343015bd --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-sk/strings.xml @@ -0,0 +1,21 @@ + + + + + "Upozornenie" + diff --git a/compose/material/material/src/androidMain/res/values-sl/strings.xml b/compose/material/material/src/androidMain/res/values-sl/strings.xml new file mode 100644 index 0000000000000..dc20efebc6324 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-sl/strings.xml @@ -0,0 +1,21 @@ + + + + + "Opozorilo" + diff --git a/compose/material/material/src/androidMain/res/values-sq/strings.xml b/compose/material/material/src/androidMain/res/values-sq/strings.xml new file mode 100644 index 0000000000000..9ffe4d831d8a5 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-sq/strings.xml @@ -0,0 +1,21 @@ + + + + + "Sinjalizim" + diff --git a/compose/material/material/src/androidMain/res/values-sr/strings.xml b/compose/material/material/src/androidMain/res/values-sr/strings.xml new file mode 100644 index 0000000000000..cb5dd4b49f631 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-sr/strings.xml @@ -0,0 +1,21 @@ + + + + + "Обавештење" + diff --git a/compose/material/material/src/androidMain/res/values-sv/strings.xml b/compose/material/material/src/androidMain/res/values-sv/strings.xml new file mode 100644 index 0000000000000..0a54abb24388c --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-sv/strings.xml @@ -0,0 +1,21 @@ + + + + + "Varning" + diff --git a/compose/material/material/src/androidMain/res/values-sw/strings.xml b/compose/material/material/src/androidMain/res/values-sw/strings.xml new file mode 100644 index 0000000000000..71dbff20a50d7 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-sw/strings.xml @@ -0,0 +1,21 @@ + + + + + "Arifa" + diff --git a/compose/material/material/src/androidMain/res/values-ta/strings.xml b/compose/material/material/src/androidMain/res/values-ta/strings.xml new file mode 100644 index 0000000000000..838deaeaac8b2 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ta/strings.xml @@ -0,0 +1,21 @@ + + + + + "விழிப்பூட்டல்" + diff --git a/compose/material/material/src/androidMain/res/values-te/strings.xml b/compose/material/material/src/androidMain/res/values-te/strings.xml new file mode 100644 index 0000000000000..a22e1e361f6f1 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-te/strings.xml @@ -0,0 +1,21 @@ + + + + + "హెచ్చరిక" + diff --git a/compose/material/material/src/androidMain/res/values-th/strings.xml b/compose/material/material/src/androidMain/res/values-th/strings.xml new file mode 100644 index 0000000000000..01548a3906825 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-th/strings.xml @@ -0,0 +1,21 @@ + + + + + "การแจ้งเตือน" + diff --git a/compose/material/material/src/androidMain/res/values-tl/strings.xml b/compose/material/material/src/androidMain/res/values-tl/strings.xml new file mode 100644 index 0000000000000..8e0e6a10e95cd --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-tl/strings.xml @@ -0,0 +1,21 @@ + + + + + "Alerto" + diff --git a/compose/material/material/src/androidMain/res/values-tr/strings.xml b/compose/material/material/src/androidMain/res/values-tr/strings.xml new file mode 100644 index 0000000000000..673d3be0f25e3 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-tr/strings.xml @@ -0,0 +1,21 @@ + + + + + "Uyarı" + diff --git a/compose/material/material/src/androidMain/res/values-uk/strings.xml b/compose/material/material/src/androidMain/res/values-uk/strings.xml new file mode 100644 index 0000000000000..1d6b358b340ce --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-uk/strings.xml @@ -0,0 +1,21 @@ + + + + + "Сповіщення" + diff --git a/compose/material/material/src/androidMain/res/values-ur/strings.xml b/compose/material/material/src/androidMain/res/values-ur/strings.xml new file mode 100644 index 0000000000000..4985a539f5d00 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-ur/strings.xml @@ -0,0 +1,21 @@ + + + + + "الرٹ" + diff --git a/compose/material/material/src/androidMain/res/values-uz/strings.xml b/compose/material/material/src/androidMain/res/values-uz/strings.xml new file mode 100644 index 0000000000000..7d49ab63f2f69 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-uz/strings.xml @@ -0,0 +1,21 @@ + + + + + "Signal" + diff --git a/compose/material/material/src/androidMain/res/values-vi/strings.xml b/compose/material/material/src/androidMain/res/values-vi/strings.xml new file mode 100644 index 0000000000000..dca5b9c71f45c --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-vi/strings.xml @@ -0,0 +1,21 @@ + + + + + "Cảnh báo" + diff --git a/compose/material/material/src/androidMain/res/values-zh-rCN/strings.xml b/compose/material/material/src/androidMain/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000000..98d4cfe26ecb0 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-zh-rCN/strings.xml @@ -0,0 +1,21 @@ + + + + + "提醒" + diff --git a/compose/material/material/src/androidMain/res/values-zh-rHK/strings.xml b/compose/material/material/src/androidMain/res/values-zh-rHK/strings.xml new file mode 100644 index 0000000000000..c8fdec1ac849b --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-zh-rHK/strings.xml @@ -0,0 +1,21 @@ + + + + + "警示" + diff --git a/compose/material/material/src/androidMain/res/values-zh-rTW/strings.xml b/compose/material/material/src/androidMain/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000000..b02cdec972359 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-zh-rTW/strings.xml @@ -0,0 +1,21 @@ + + + + + "快訊" + diff --git a/compose/material/material/src/androidMain/res/values-zu/strings.xml b/compose/material/material/src/androidMain/res/values-zu/strings.xml new file mode 100644 index 0000000000000..b297877189d30 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values-zu/strings.xml @@ -0,0 +1,21 @@ + + + + + "Isexwayiso" + diff --git a/compose/material/material/src/androidMain/res/values/public.xml b/compose/material/material/src/androidMain/res/values/public.xml new file mode 100644 index 0000000000000..4d542549da745 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values/public.xml @@ -0,0 +1,18 @@ + + + + diff --git a/compose/material/material/src/androidMain/res/values/strings.xml b/compose/material/material/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000000000..c5cc724df5395 --- /dev/null +++ b/compose/material/material/src/androidMain/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + + + Alert + diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BackdropScaffold.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BackdropScaffold.kt index 2550ab9bde75f..8e9bc84c5e82c 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BackdropScaffold.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BackdropScaffold.kt @@ -386,7 +386,7 @@ fun BackdropScaffold( } } val calculateBackLayerConstraints: (Constraints) -> Constraints = { - it.copyMaxDimensions().offset(vertical = -headerHeightPx.roundToInt()) + it.copy(minWidth = 0, minHeight = 0).offset(vertical = -headerHeightPx.roundToInt()) } val state = scaffoldState.anchoredDraggableState diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt index d1c8e026d4332..82c2cbd67b37b 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt @@ -487,7 +487,7 @@ private fun BottomSheetScaffoldLayout( constraints -> val layoutWidth = constraints.maxWidth val layoutHeight = constraints.maxHeight - val looseConstraints = constraints.copyMaxDimensions() + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) val sheetPlaceables = sheetMeasurables.fastMap { it.measure(looseConstraints) } diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ExposedDropdownMenu.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ExposedDropdownMenu.kt index 7590e50f121f0..657b9ea79bb03 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ExposedDropdownMenu.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ExposedDropdownMenu.kt @@ -13,9 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package androidx.compose.material import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState import androidx.compose.foundation.gestures.awaitEachGesture @@ -23,23 +25,43 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.internal.ExposedDropdownMenuPopup import androidx.compose.material.internal.Icons import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntRect +import kotlin.math.max /** * [Material Design Exposed Dropdown @@ -62,12 +84,69 @@ import androidx.compose.ui.semantics.semantics */ @ExperimentalMaterialApi @Composable -expect fun ExposedDropdownMenuBox( +fun ExposedDropdownMenuBox( expanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, content: @Composable ExposedDropdownMenuBoxScope.() -> Unit -) +) { + val density = LocalDensity.current + val windowBoundsCalculator = platformWindowBoundsCalculator() + var width by remember { mutableIntStateOf(0) } + var menuHeight by remember { mutableIntStateOf(0) } + val verticalMarginInPx = with(density) { MenuVerticalMargin.roundToPx() } + val coordinates = remember { Ref() } + + val scope = + remember(density, menuHeight, width) { + object : ExposedDropdownMenuBoxScope() { + override fun Modifier.exposedDropdownSize(matchTextFieldWidth: Boolean): Modifier { + return with(density) { + heightIn(max = menuHeight.toDp()).let { + if (matchTextFieldWidth) { + it.width(width.toDp()) + } else it + } + } + } + } + } + val focusRequester = remember { FocusRequester() } + + Box( + modifier + .onGloballyPositioned { + width = it.size.width + coordinates.value = it + updateHeight( + windowBounds = windowBoundsCalculator.getVisibleWindowBounds(), + coordinates = coordinates.value, + verticalMarginInPx = verticalMarginInPx + ) { newHeight -> + menuHeight = newHeight + } + } + .expandable( + onExpandedChange = { onExpandedChange(!expanded) }, + menuLabel = getString(Strings.ExposedDropdownMenu) + ) + .focusRequester(focusRequester) + ) { + scope.content() + } + + SideEffect { if (expanded) focusRequester.requestFocus() } + + OnPlatformWindowBoundsChange { + updateHeight( + windowBounds = windowBoundsCalculator.getVisibleWindowBounds(), + coordinates = coordinates.value, + verticalMarginInPx = verticalMarginInPx + ) { newHeight -> + menuHeight = newHeight + } + } +} /** Scope for [ExposedDropdownMenuBox]. */ @ExperimentalMaterialApi @@ -103,21 +182,42 @@ abstract class ExposedDropdownMenuBoxScope { scrollState: ScrollState = rememberScrollState(), content: @Composable ColumnScope.() -> Unit ) { - ExposedDropdownMenuDefaultImpl( - expanded, onDismissRequest, modifier, scrollState, content - ) + // TODO(b/202810604): use DropdownMenu when PopupProperties constructor is stable + // return DropdownMenu( + // expanded = expanded, + // onDismissRequest = onDismissRequest, + // modifier = modifier.exposedDropdownSize(), + // properties = ExposedDropdownMenuDefaults.PopupProperties, + // content = content + // ) + + val expandedStates = remember { MutableTransitionState(false) } + expandedStates.targetState = expanded + + if (expandedStates.currentState || expandedStates.targetState) { + val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } + val density = LocalDensity.current + val popupPositionProvider = + DropdownMenuPositionProvider(DpOffset.Zero, density) { parentBounds, menuBounds -> + transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds) + } + + ExposedDropdownMenuPopup( + onDismissRequest = onDismissRequest, + popupPositionProvider = popupPositionProvider + ) { + DropdownMenuContent( + expandedStates = expandedStates, + transformOriginState = transformOriginState, + scrollState = scrollState, + modifier = modifier.exposedDropdownSize(), + content = content + ) + } + } } } -@Composable -internal expect fun ExposedDropdownMenuBoxScope.ExposedDropdownMenuDefaultImpl( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - scrollState: ScrollState = rememberScrollState(), - content: @Composable ColumnScope.() -> Unit -) - /** Contains default values used by Exposed Dropdown Menu. */ @ExperimentalMaterialApi object ExposedDropdownMenuDefaults { @@ -338,19 +438,19 @@ object ExposedDropdownMenuDefaults { ) } -internal fun Modifier.expandable(onExpandedChange: () -> Unit, menuLabel: String) = +private fun Modifier.expandable(onExpandedChange: () -> Unit, menuLabel: String) = pointerInput(onExpandedChange) { - awaitEachGesture { - // Must be PointerEventPass.Initial to observe events before the text field consumes - // them - // in the Main pass - awaitFirstDown(pass = PointerEventPass.Initial) - val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) - if (upEvent != null) { - onExpandedChange() + awaitEachGesture { + // Must be PointerEventPass.Initial to observe events before the text field consumes + // them + // in the Main pass + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + onExpandedChange() + } } } - } .semantics { contentDescription = menuLabel // this should be a localised string onClick { @@ -359,7 +459,18 @@ internal fun Modifier.expandable(onExpandedChange: () -> Unit, menuLabel: String } } -@OptIn(ExperimentalMaterialApi::class) +private fun updateHeight( + windowBounds: IntRect, + coordinates: LayoutCoordinates?, + verticalMarginInPx: Int, + onHeightUpdate: (Int) -> Unit +) { + coordinates ?: return + val heightAbove = coordinates.boundsInWindow().top - windowBounds.top + val heightBelow = windowBounds.bottom - windowBounds.top - coordinates.boundsInWindow().bottom + onHeightUpdate(max(heightAbove, heightBelow).toInt() - verticalMarginInPx) +} + @Immutable private class DefaultTextFieldForExposedDropdownMenusColors( private val textColor: Color, @@ -546,3 +657,11 @@ private class DefaultTextFieldForExposedDropdownMenusColors( return result } } + +internal expect class WindowBoundsCalculator { + fun getVisibleWindowBounds(): IntRect +} + +@Composable internal expect fun platformWindowBoundsCalculator(): WindowBoundsCalculator + +@Composable internal expect fun OnPlatformWindowBoundsChange(block: () -> Unit) diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/InternalMutatorMutex.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/InternalMutatorMutex.kt index f9939ea3b9d18..0ffc3db67cd5e 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/InternalMutatorMutex.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/InternalMutatorMutex.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.sync.withLock * This is an internal copy of androidx.compose.foundation.MutatorMutex with an additional tryMutate * method. Do not modify, except for tryMutate. ** */ -internal expect class AtomicReference(value: V) { +internal expect class InternalAtomicReference(value: V) { fun get(): V fun set(value: V) @@ -60,7 +60,7 @@ internal class InternalMutatorMutex { fun cancel() = job.cancel() } - private val currentMutator = AtomicReference(null) + private val currentMutator = InternalAtomicReference(null) private val mutex = Mutex() private fun tryMutateOrCancel(mutator: Mutator) { diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/OutlinedTextField.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/OutlinedTextField.kt index 07e05d8ede50d..be850bbcbad09 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/OutlinedTextField.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/OutlinedTextField.kt @@ -772,7 +772,7 @@ private class OutlinedTextFieldMeasurePolicy( val bottomPadding = paddingValues.calculateBottomPadding().roundToPx() // measure leading icon - val relaxedConstraints = constraints.copyMaxDimensions() + val relaxedConstraints = constraints.copy(minWidth = 0, minHeight = 0) val leadingPlaceable = measurables.fastFirstOrNull { it.layoutId == LeadingId }?.measure(relaxedConstraints) occupiedSpaceHorizontally += widthOrZero(leadingPlaceable) diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt index dc7e6ce671ed1..2a16cb3050163 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt @@ -29,7 +29,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.UiComposable @@ -374,11 +377,31 @@ private fun ScaffoldLayout( contentWindowInsets: WindowInsets, bottomBar: @Composable @UiComposable () -> Unit ) { + // Create the backing value for the content padding + // These values will be updated during measurement, but before subcomposing the body content + // Remembering and updating a single PaddingValues avoids needing to recompose when the values + // change + val contentPadding = remember { + object : PaddingValues { + var paddingHolder by mutableStateOf(PaddingValues(0.dp)) + + override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = + paddingHolder.calculateLeftPadding(layoutDirection) + + override fun calculateTopPadding(): Dp = paddingHolder.calculateTopPadding() + + override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = + paddingHolder.calculateRightPadding(layoutDirection) + + override fun calculateBottomPadding(): Dp = paddingHolder.calculateBottomPadding() + } + } + SubcomposeLayout { constraints -> val layoutWidth = constraints.maxWidth val layoutHeight = constraints.maxHeight - val looseConstraints = constraints.copyMaxDimensions() + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) val topBarPlaceables = subcompose(ScaffoldLayoutContent.TopBar, topBar).fastMap { @@ -486,34 +509,30 @@ private fun ScaffoldLayout( 0 } + // Update the backing state for the content padding before subcomposing the body + val insets = contentWindowInsets.asPaddingValues(this) + contentPadding.paddingHolder = + PaddingValues( + top = + if (topBarPlaceables.isEmpty()) { + insets.calculateTopPadding() + } else { + 0.dp + }, + bottom = + if (bottomBarPlaceables.isEmpty() || bottomBarHeight == null) { + insets.calculateBottomPadding() + } else { + bottomBarHeight.toDp() + }, + start = insets.calculateStartPadding(layoutDirection), + end = insets.calculateEndPadding(layoutDirection) + ) + val bodyContentHeight = layoutHeight - topBarHeight val bodyContentPlaceables = - subcompose(ScaffoldLayoutContent.MainContent) { - val insets = contentWindowInsets.asPaddingValues(this@SubcomposeLayout) - val innerPadding = - PaddingValues( - top = - if (topBarPlaceables.isEmpty()) { - insets.calculateTopPadding() - } else { - 0.dp - }, - bottom = - if (bottomBarPlaceables.isEmpty() || bottomBarHeight == null) { - insets.calculateBottomPadding() - } else { - bottomBarHeight.toDp() - }, - start = - insets.calculateStartPadding( - (this@SubcomposeLayout).layoutDirection - ), - end = - insets.calculateEndPadding((this@SubcomposeLayout).layoutDirection) - ) - content(innerPadding) - } + subcompose(ScaffoldLayoutContent.MainContent) { content(contentPadding) } .fastMap { it.measure(looseConstraints.copy(maxHeight = bodyContentHeight)) } layout(layoutWidth, layoutHeight) { diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/SecureTextField.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/SecureTextField.kt index 948751be0ae76..253ad5c1e9360 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/SecureTextField.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/SecureTextField.kt @@ -61,9 +61,6 @@ import androidx.compose.ui.text.input.VisualTransformation * @param enabled controls the enabled state of the [TextField]. When `false`, the text field will * be neither editable nor focusable, the input of the text field will not be selectable, visually * text field will appear in the disabled UI state. - * @param readOnly controls the editable state of the [TextField]. When `true`, the text field can - * not be modified. However, a user can focus it. Read-only text fields are usually used to - * display pre-filled forms that user can not edit. * @param textStyle the style to be applied to the input text. The default [textStyle] uses the * [LocalTextStyle] defined by the theme. * @param label the optional label to be displayed inside the text field container. The default text @@ -108,7 +105,6 @@ fun SecureTextField( state: TextFieldState, modifier: Modifier = Modifier, enabled: Boolean = true, - readOnly: Boolean = false, textStyle: TextStyle = LocalTextStyle.current, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null, @@ -142,7 +138,6 @@ fun SecureTextField( minHeight = TextFieldDefaults.MinHeight ), enabled = enabled, - readOnly = readOnly, textStyle = mergedTextStyle, cursorBrush = SolidColor(colors.cursorColor(isError).value), inputTransformation = inputTransformation, @@ -189,9 +184,6 @@ fun SecureTextField( * @param enabled controls the enabled state of the [OutlinedTextField]. When `false`, the text * field will be neither editable nor focusable, the input of the text field will not be * selectable, visually text field will appear in the disabled UI state - * @param readOnly controls the editable state of the [OutlinedTextField]. When `true`, the text - * field can not be modified, however, a user can focus it and copy text from it. Read-only text - * fields are usually used to display pre-filled forms that user can not edit * @param textStyle the style to be applied to the input text. The default [textStyle] uses the * [LocalTextStyle] defined by the theme * @param label the optional label to be displayed inside the text field container. The default text @@ -236,7 +228,6 @@ fun OutlinedSecureTextField( state: TextFieldState, modifier: Modifier = Modifier, enabled: Boolean = true, - readOnly: Boolean = false, textStyle: TextStyle = LocalTextStyle.current, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null, @@ -282,7 +273,6 @@ fun OutlinedSecureTextField( minHeight = TextFieldDefaults.MinHeight ), enabled = enabled, - readOnly = readOnly, textStyle = mergedTextStyle, cursorBrush = SolidColor(colors.cursorColor(isError).value), inputTransformation = inputTransformation, diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt index 75f55a56d81d7..aa589909f7b3b 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt @@ -67,19 +67,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.AwaitPointerEventScope @@ -170,9 +169,7 @@ fun Slider( val onValueChangeState = rememberUpdatedState(onValueChange) val onValueChangeFinishedState = rememberUpdatedState(onValueChangeFinished) val tickFractions = remember(steps) { stepsToTickFractions(steps) } - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - val focusRequester = remember { FocusRequester() } BoxWithConstraints( modifier .minimumInteractiveComponentSize() @@ -185,10 +182,18 @@ fun Slider( valueRange, steps ) - .focusRequester(focusRequester) .focusable(enabled, interactionSource) - .slideOnKeyEvents(enabled, steps, valueRange, value, isRtl, onValueChangeState, onValueChangeFinishedState) + .slideOnKeyEvents( + enabled, + steps, + valueRange, + value, + LocalLayoutDirection.current == LayoutDirection.Rtl, + onValueChangeState, + onValueChangeFinishedState + ) ) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val widthPx = constraints.maxWidth.toFloat() val maxPx: Float val minPx: Float @@ -224,7 +229,6 @@ fun Slider( rememberUpdatedState<(Float) -> Unit> { velocity: Float -> val current = rawOffset.floatValue val target = snapValueToTick(current, tickFractions, minPx, maxPx) - focusRequester.requestFocus() if (current != target) { scope.launch { animateToTarget(draggableState, current, target, velocity) @@ -272,8 +276,6 @@ fun Slider( } } -// TODO: Edge case - losing focus on slider while key is pressed will end up with onValueChangeFinished not being invoked -@OptIn(ExperimentalComposeUiApi::class) private fun Modifier.slideOnKeyEvents( enabled: Boolean, steps: Int, @@ -284,75 +286,71 @@ private fun Modifier.slideOnKeyEvents( onValueChangeFinishedState: State<(() -> Unit)?> ): Modifier { require(steps >= 0) { "steps should be >= 0" } - return this.onKeyEvent { if (!enabled) return@onKeyEvent false - when (it.type) { KeyEventType.KeyDown -> { val rangeLength = abs(valueRange.endInclusive - valueRange.start) - // When steps == 0, it means that a user is not limited by a step length (delta) when using touch or mouse. - // But it is not possible to adjust the value continuously when using keyboard buttons - - // the delta has to be discrete. In this case, 1% of the valueRange seems to make sense. + // When steps == 0, it means that a user is not limited by a step length (delta) + // when using touch or mouse. But it is not possible to adjust the value + // continuously when using keyboard buttons - the delta has to be discrete. + // In this case, 1% of the valueRange seems to make sense. val actualSteps = if (steps > 0) steps + 1 else 100 val delta = rangeLength / actualSteps - when { - it.isDirectionUp -> { + when (it.key) { + Key.DirectionUp -> { onValueChangeState.value((value + delta).coerceIn(valueRange)) true } - - it.isDirectionDown -> { + Key.DirectionDown -> { onValueChangeState.value((value - delta).coerceIn(valueRange)) true } - - it.isDirectionRight -> { + Key.DirectionRight -> { val sign = if (isRtl) -1 else 1 onValueChangeState.value((value + sign * delta).coerceIn(valueRange)) true } - - it.isDirectionLeft -> { + Key.DirectionLeft -> { val sign = if (isRtl) -1 else 1 onValueChangeState.value((value - sign * delta).coerceIn(valueRange)) true } - - it.isHome -> { + Key.MoveHome -> { onValueChangeState.value(valueRange.start) true } - - it.isMoveEnd -> { + Key.MoveEnd -> { onValueChangeState.value(valueRange.endInclusive) true } - - it.isPgUp -> { + Key.PageUp -> { val page = (actualSteps / 10).coerceIn(1, 10) onValueChangeState.value((value - page * delta).coerceIn(valueRange)) true } - - it.isPgDn -> { + Key.PageDown -> { val page = (actualSteps / 10).coerceIn(1, 10) onValueChangeState.value((value + page * delta).coerceIn(valueRange)) true } - else -> false } } - KeyEventType.KeyUp -> { - if (it.isDirectionDown || it.isDirectionUp || it.isDirectionRight - || it.isDirectionLeft || it.isHome || it.isMoveEnd || it.isPgUp || it.isPgDn - ) { - onValueChangeFinishedState.value?.invoke() - true - } else { - false + when (it.key) { + Key.DirectionUp, + Key.DirectionDown, + Key.DirectionRight, + Key.DirectionLeft, + Key.MoveHome, + Key.MoveEnd, + Key.PageUp, + Key.PageDown -> { + onValueChangeFinishedState.value?.invoke() + true + } + else -> false } } else -> false diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/TextField.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/TextField.kt index 6a253ba8bcd02..1064835d23020 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/TextField.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/TextField.kt @@ -725,7 +725,7 @@ private class TextFieldMeasurePolicy( var occupiedSpaceHorizontally = 0 // measure leading icon - val looseConstraints = constraints.copyMaxDimensions() + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) val leadingPlaceable = measurables.fastFirstOrNull { it.layoutId == LeadingId }?.measure(looseConstraints) occupiedSpaceHorizontally += widthOrZero(leadingPlaceable) diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.kt new file mode 100644 index 0000000000000..3d6e140cc7f19 --- /dev/null +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.kt @@ -0,0 +1,30 @@ +/* + * 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. + */ + +package androidx.compose.material.internal + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.PopupPositionProvider + +/** + * Popup specific for exposed dropdown menus. b/202810604. Should not be used in other components. + */ +@Composable +internal expect fun ExposedDropdownMenuPopup( + onDismissRequest: (() -> Unit)? = null, + popupPositionProvider: PopupPositionProvider, + content: @Composable () -> Unit +) diff --git a/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/AtomicActual.kt b/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/AtomicActual.kt index bc02bcc2e834a..56975e2e0ebd8 100644 --- a/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/AtomicActual.kt +++ b/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/AtomicActual.kt @@ -18,7 +18,7 @@ package androidx.compose.material import kotlinx.atomicfu.atomic -internal actual class AtomicReference actual constructor(value: V) { +internal actual class InternalAtomicReference actual constructor(value: V) { private val delegate = atomic(value) actual fun get() = delegate.value actual fun set(value: V) { diff --git a/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/ExposedDropdownMenu.skiko.kt b/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/ExposedDropdownMenu.skiko.kt index 2f36eb1e065d6..4525e01d1632c 100644 --- a/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/ExposedDropdownMenu.skiko.kt +++ b/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/ExposedDropdownMenu.skiko.kt @@ -16,93 +16,30 @@ package androidx.compose.material -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.toIntRect -import kotlin.math.max -@OptIn(ExperimentalComposeUiApi::class) -@ExperimentalMaterialApi +internal actual class WindowBoundsCalculator(private val windowInfo: WindowInfo) { + actual fun getVisibleWindowBounds(): IntRect = windowInfo.containerSize.toIntRect() +} + @Composable -actual fun ExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable ExposedDropdownMenuBoxScope.() -> Unit -) { - val density = LocalDensity.current +internal actual fun platformWindowBoundsCalculator(): WindowBoundsCalculator { val windowInfo = LocalWindowInfo.current - var width by remember { mutableIntStateOf(0) } - var menuHeight by remember { mutableIntStateOf(0) } - val verticalMarginInPx = with(density) { MenuVerticalMargin.roundToPx() } - - val scope = - remember(density, menuHeight, width) { - object : ExposedDropdownMenuBoxScope() { - override fun Modifier.exposedDropdownSize(matchTextFieldWidth: Boolean): Modifier { - return with(density) { - heightIn(max = menuHeight.toDp()).let { - if (matchTextFieldWidth) { - it.width(width.toDp()) - } else it - } - } - } - } - } - val focusRequester = remember { FocusRequester() } - - Box( - modifier - .onGloballyPositioned { - width = it.size.width - val boundsInWindow = it.boundsInWindow() - val visibleWindowBounds = windowInfo.containerSize.toIntRect() - val heightAbove = boundsInWindow.top - visibleWindowBounds.top - val heightBelow = visibleWindowBounds.height - boundsInWindow.bottom - menuHeight = max(heightAbove, heightBelow).toInt() - verticalMarginInPx - } - .expandable( - onExpandedChange = { onExpandedChange(!expanded) }, - menuLabel = getString(Strings.ExposedDropdownMenu) - ) - .focusRequester(focusRequester) - ) { - scope.content() - } - - SideEffect { if (expanded) focusRequester.requestFocus() } + return remember(windowInfo) { WindowBoundsCalculator(windowInfo) } } @Composable -internal actual fun ExposedDropdownMenuBoxScope.ExposedDropdownMenuDefaultImpl( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - scrollState: ScrollState, - content: @Composable ColumnScope.() -> Unit -) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - modifier = modifier.exposedDropdownSize(), - content = content - ) +internal actual fun OnPlatformWindowBoundsChange(block: () -> Unit) { + val windowInfo = LocalWindowInfo.current + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.containerSize } + .collect { block() } + } } diff --git a/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.skiko.kt b/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.skiko.kt new file mode 100644 index 0000000000000..e3c38fec7e5b5 --- /dev/null +++ b/compose/material/material/src/skikoMain/kotlin/androidx/compose/material/internal/ExposedDropdownMenuPopup.skiko.kt @@ -0,0 +1,51 @@ +/* + * 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. + */ + +package androidx.compose.material.internal + +import androidx.compose.material.handlePopupOnKeyEvent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.InputModeManager +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties + +@Composable +internal actual fun ExposedDropdownMenuPopup( + onDismissRequest: (() -> Unit)?, + popupPositionProvider: PopupPositionProvider, + content: @Composable () -> Unit +) { + var focusManager: FocusManager? by mutableStateOf(null) + var inputModeManager: InputModeManager? by mutableStateOf(null) + Popup( + popupPositionProvider = popupPositionProvider, + onDismissRequest = onDismissRequest, + properties = PopupProperties(), + onKeyEvent = { + handlePopupOnKeyEvent(it, focusManager, inputModeManager) + }) { + focusManager = LocalFocusManager.current + inputModeManager = LocalInputModeManager.current + content() + } +} diff --git a/compose/runtime/runtime-lint/src/test/java/androidx/compose/runtime/lint/CompositionLocalNamingDetectorTest.kt b/compose/runtime/runtime-lint/src/test/java/androidx/compose/runtime/lint/CompositionLocalNamingDetectorTest.kt index 233445eb8d4ab..c4fdde36a380a 100644 --- a/compose/runtime/runtime-lint/src/test/java/androidx/compose/runtime/lint/CompositionLocalNamingDetectorTest.kt +++ b/compose/runtime/runtime-lint/src/test/java/androidx/compose/runtime/lint/CompositionLocalNamingDetectorTest.kt @@ -18,7 +18,7 @@ package androidx.compose.runtime.lint -import androidx.compose.lint.test.bytecodeStub +import androidx.compose.lint.test.Stubs import com.android.tools.lint.checks.infrastructure.LintDetectorTest import com.android.tools.lint.detector.api.Detector import com.android.tools.lint.detector.api.Issue @@ -35,132 +35,6 @@ class CompositionLocalNamingDetectorTest : LintDetectorTest() { override fun getIssues(): MutableList = mutableListOf(CompositionLocalNamingDetector.CompositionLocalNaming) - // Simplified CompositionLocal.kt stubs - private val compositionLocalStub = - bytecodeStub( - filename = "CompositionLocal.kt", - filepath = "androidx/compose/runtime", - checksum = 0xeda1836e, - """ - package androidx.compose.runtime - - sealed class CompositionLocal constructor(defaultFactory: (() -> T)? = null) - - abstract class ProvidableCompositionLocal internal constructor( - defaultFactory: (() -> T)? - ) : CompositionLocal(defaultFactory) - - internal class DynamicProvidableCompositionLocal constructor( - defaultFactory: (() -> T)? - ) : ProvidableCompositionLocal(defaultFactory) - - internal class StaticProvidableCompositionLocal( - defaultFactory: (() -> T)? - ) : ProvidableCompositionLocal(defaultFactory) - - fun compositionLocalOf( - defaultFactory: (() -> T)? = null - ): ProvidableCompositionLocal = DynamicProvidableCompositionLocal(defaultFactory) - - fun staticCompositionLocalOf( - defaultFactory: (() -> T)? = null - ): ProvidableCompositionLocal = StaticProvidableCompositionLocal(defaultFactory) - """, - """ - META-INF/main.kotlin_module: - H4sIAAAAAAAA/2NgYGBmYGBgBGJOBijg0uOSSMxLKcrPTKnQS87PLcgvTtUr - Ks0rycxNFRJyBgtklmTm5/nkJyfmeJdwqXHJ4FKvl5afL8QWklpc4l2ixKDF - AAAiXiK7cAAAAA== - """, - """ - androidx/compose/runtime/CompositionLocal.class: - H4sIAAAAAAAA/5VTS08TURT+7nT6YOQxFMWCqCCofShTiRoUQlQMSZMiCk03 - LMxlZsBL2xkyc9vgxjT+C7f+A1YkLkzD0h9lPHdalKCksjmv+51zvjPnzI+f - 374DeAyLIcc9J/CFc2jZfuPAD10raHpSNFxrNfKFFL5X9m1eT4IxZJcrz8v7 - vMWtOvf2rI2dfdeWSyt/hxjM87EkdIbEsvCEXGGYzZZrvqwLz9pvNazdpmer - TqG11rOKS7kq0euHWi5UKksrETaWzVUHkcCAgTgMBl1+ECFDofzfIxLrYcfd - 5c26XOO29IOPDNP9aDJs9h2ldBYgPOkGHq9br7utVgkrg6bqt86DmhtE07zr - W/SyNdW3GTWgIa2spLKuMozN2X+w7xsRmGH+csUZRk8T1l3JHS45xbRGK0Z3 - xpQYUAIMrEbxQ6G8IlnOI4aNTnvc0DKa0WkbmklC6Z6b0VJPM512Xk912iZb - 0Iraq4mUnjZTmhmbNNJ6WlvstDOsqJ98TWhm/OQzYwlVdoE6VZhqmD4ldnZj - Ty6+ibeB3xIO36m7/7iOsfOx+ZqkQ1v1HZdhpCw8902zseMGFZWveitMlQdC - +b3gwJbY87hsBmQPbUlu19b5Qe/N2PKbge2uCeVMbHYpVUUo6PWl5/mSR0eg - z9Du4uqDIkYWLZNklrxnNLBGOp4/xpUjMjTkSCYoDOjIkxzvAjCIoahAHMMY - ofdChE6Z9IvCJF+VK1J5nfTIlP7pC+KsnC+wY4x1Cz+IurPU2Q5R9mgve5FA - as+JfOEY146i9SsG093obwaJHgNljeN6lNVlE8PDSN/HPOkXhMkQ34ltxEqY - LOFGCVO4SSZulXAb09tgIWZwZxvJEEMhZkPMhRgOcTdUkXu/AG89zFz4BAAA - """, - """ - androidx/compose/runtime/CompositionLocalKt.class: - H4sIAAAAAAAA/51UW08TQRT+ZntfCpSiUIoXhKpclC0gqLSSGAyxoVwiDcbw - NN0uZHrZJbvbBl4Mf8N/4ZtGE9Nnf5TxzLZELZSavpw5M+eb73x7zpn9+evb - DwDP8IJhgZsl2xKlM023aqeWY2h23XRFzdA2vb1whWXmLZ1Xt90QGEOszBtc - q3LzRNsrlg2dTn0Mcb0DvXfM8H42X7HcqjC1cqOmHddNXYYdbavtpTNz+a7p - 922rIUq8WDU6hWQYzrKF9XynkMxGr3zZhUIhs9Ff1qx3lWEmb9knWtlwizYX - RM5N03J5K9Gu5e7Wq1Jg6iYUQWQGgq13FfLm3OQ1oXfXE4LKEMwKU7gbpKp3 - qQ+jiGJQxQCGGFb6qEAIMYahknHM61V3i+uuZZ8zTPVKzJC8OhypNg1Duafy - 3NVO9zc3UQQQVKFgjCHhyHbonRg5ti+7ch94d26q0ISKpKzvVDf6yw+PItHS - cpdh5LICO4bLS9zlVDOl1vDRG2XSRKQBA6tIR6HgmZBemrzSEkOpeRFTmxeq - klBUJax4a/MimYqRCcf9ceWtkmbT/jDBlOVYWIn5kmrrOMHSfsIF/gMmcy0z - rPb5YlmBfhKX3/n3cIx2ghcrNBX+TatkMAznhWns1mtFwy5IYskhMYfcFnLf - PowciBOTu3Wb/Ml3LTk5syEcQeHXf54evcvO6D63ec1wDfsf2CB1Wq/s8NN2 - AvXAqtu6sSXkZqLNcXiFH0vUUL9sFtkJOW20arRbg4/6B0S/Y+DD/BcMNzHy - WfYSabJBL3aLbhOihUMco7Que5gQVtqosPfbBkJUUESAWISu3Sa/lUSBHIvB - Sf/HTwiw7fmvGG9lWSVLCsJeuiEPFSfCUSKMk9DEdUKTUujkNUIT/Qm9c6PQ - e12FjhHhOBGOUXjNAy3iOa2viO0+1XjqCL4cHuQwncMMUjk8xKMcHmP2CMzB - HOaPEHQQcLDg4ImDuIOnDhK/AezqFjcEBwAA - """, - """ - androidx/compose/runtime/DynamicProvidableCompositionLocal.class: - H4sIAAAAAAAA/51SXU8TQRQ9sy0tXaGUIljwAxQ0AolbUBNDaxPFEJpUJNLw - wtN0d6jT7s6a3dkG3vpb/Ac+mfhgGh/9UcY7bYloQkh4mXvumXPP3Lkzv35/ - /wHgBZ4w7HDlRaH0zhw3DD6HsXCiRGkZCOfdueKBdA+jsCc93vLF7lAgtQxV - I3S5nwVjOKw2dxod3uOOz1Xb+dDqCFdXao0rba/2qzablVqF4fkNarNIM2Sq - UkldY1h92uiG2pfK6fQC5zRRrhHGzt4YlSvrxwzr16mqm8OOjHatEUZtpyN0 - K+KSNFypUPOR/iDxfdNTZQoZZG1MwGZI608yZqhePYhr50ujyHvilCe+3uOu - DqNzhpXrLsYweyF5LzT3uObEWUEvRS/OzJIzCxhYl/gzabIyIW+LYX/QL9pW - ybIH/X/D5KBfGvQ30hQLbHuymC5a+6xsvZ0v5gupJdvkr0jCyumfXzJWYcL4 - bdMRTYaXN/kK1HLx4hqX7zb3v/BZV9Osd0NPMMw0pBIHSdASUdOYGg+jOeaR - NPmYzB3JtuI6iQivfRy1Ulc9GUvaPuQRD4QW0Zu/D8xgH4VJ5Io9aeoXxzXH - o4pLQmzBotcfj9d8BqSwTFmNeItiZmPzG259JWRhhVZ7yE6ZH4OHhBZGKmKm - hy4Z5DFDTo+GFZNYpZg11jkCqTGdwtowPsBjiq9pt0CGsydI1VGsY66O25gn - iIU67qB0AhZjEUsnyMSYjnE3xr0Y+Rj3Y2T/AJbWL04aBAAA - """, - """ - androidx/compose/runtime/ProvidableCompositionLocal.class: - H4sIAAAAAAAA/41SXU9TQRA9e1vacsVSikLBLxBEgcRbURNjK0YxjTUFURpe - eNr2Lrjt7V6zd2+Db/0t/gOfTHwwjY/+KONsWyKSEHjZmT1z5szszP7+8+Mn - gCe4z/CYK1+H0j/2mmHncxgJT8fKyI7wdnXYlT5vBGJrEJFGhqoWNnmQBmOo - lOvPay3e5V7A1ZH3vtESTVParJ2rd1alXK+XNksMq5fOSCPJkCpLJc0mw9KD - Wjs0gVReq9vxDmPVtMTIq4y8Yml1n9QvYpXXB31Y7nIt1EdeS5iG5pI4XKnQ - 8CF/Jw4COwtq+MOFhU/HpTJCKx54b8QhjwOzRVSj46YJ9TbXbaGp9ARScF2M - 4QpD0nySEcPT8wd5/mKouaw/LFPhtsIXhoWLmmWYOqFsC8N9bjhhTqeboD/C - 7DFuDzCwNuHH0t6K5PmPGN71e3nXKThuv/e/yawU+r21ZKbfy7GNTD6Zd96y - ovN6loB8NpeYdy30rN8rsGLy19eUkxuzihtUpM6wfvlfRK3mT9o//abps8SH - bUPD3Qp9wTBZk0rsxJ2G0HU7R6thOftcS3sfgeN78khxE2vylz8OG6iqrowk - hXe55h1Bu33175cwuHthrJuiIm3+3Chnf5hxiphchEPrHo2Vtp9GAgt0e0nW - IZteW2ffMfGNXAeLdLoDOEPUFO6SNzOk4SqyA5k0JpEjqaVBRgbLFrPa4+Qk - RnAC9wb2DlbIvqDoFHWRP0CiiukqrlVxHTPkYraKAuYOwCLM48YBUhGyEW5G - uBVhMsLtCOm/jQDw8EUEAAA= - """, - """ - androidx/compose/runtime/StaticProvidableCompositionLocal.class: - H4sIAAAAAAAA/51SXW8SQRQ9s1A+1pZSaiutH60WjW0Tl1ZNVJBEmzQlwUqE - 8MLTsDvFgWXX7M6S+sZv8R/4ZOKDIT76o4x3gMZqQpr0Ze65Z849c+fO/Pr9 - /QeAZ3jE8JJ7TuBL59yy/cEnPxRWEHlKDoTVUFxJux74Q+nwjiuOJvtSSd+r - +TZ3k2AM9XLzVa3Hh9xyude13nd6wlalSm2u63y/crNZqpQYnl6jNok4Q6Is - PakqDDuPa31fudKzesOBdRZ5thaG1vEMFUu7LYbdq1Tl/UlHWluo+UHX6gnV - CbgkDfc8X49H608j19U9lRaRQNLEAkyGuPooQ4bS/EFcNV6aRMYRZzxy1TG3 - lR98Zti+6l4MKxeSd0JxhytOnDEYxui9mV7SegED6xN/LnVWJOQcMJyMRznT - yBvmePRvSI1H+fFoL04xyw5TuXjOOGFF4+1aLpONbZo6f0ESVoz//JIwsgva - 75COaDI8v85PoJZzF9e4fLfV/4VP+opGfeQ7gmG5Jj1xGg06ImhqU+2hNS0e - SJ3PyHRDdj2uooBw4cO0lao3lKGk7ToP+EAoEbz5+74MZsOPAlscS12/Matp - TSsuCXEAgx5/Nl79FxDDFmUV4g2Kib39b7jxlZCBbVrNCZulmgzuE1qfqrCI - pYlLgvhlcnowqUhhh2JSW6cJxGZ0DIVJvIeHFF/TrjZcaSNWRa6K1SpuYo0g - 1qu4hXwbLMQGNttIhFgKcTvEnRCZEHdDJP8AhWEFiBgEAAA= - """ - ) - @Test fun noLocalPrefix() { lint() @@ -187,7 +61,7 @@ class CompositionLocalNamingDetectorTest : LintDetectorTest() { } """ ), - compositionLocalStub + Stubs.CompositionLocal ) .run() .expect( @@ -229,7 +103,7 @@ src/androidx/compose/runtime/foo/Test.kt:16: Warning: CompositionLocal propertie } """ ), - compositionLocalStub + Stubs.CompositionLocal ) .run() .expectClean() diff --git a/compose/runtime/runtime/api/current.ignore b/compose/runtime/runtime/api/current.ignore index f02ef5a09be85..75715a1896bcc 100644 --- a/compose/runtime/runtime/api/current.ignore +++ b/compose/runtime/runtime/api/current.ignore @@ -1,3 +1,11 @@ // Baseline format: 1.0 -AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#setShouldPauseCallback(kotlin.jvm.functions.Function0): - Added method androidx.compose.runtime.ControlledComposition.setShouldPauseCallback(kotlin.jvm.functions.Function0) +AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback): + Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback) + + +BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash: + Removed property Composer.compoundKeyHash from compatibility checked API surface +BecameUnchecked: androidx.compose.runtime.Composer#recomposeScope: + Removed property Composer.recomposeScope from compatibility checked API surface +BecameUnchecked: androidx.compose.runtime.internal.LiveLiteralKt#isLiveLiteralsEnabled: + Removed property LiveLiteralKt.isLiveLiteralsEnabled from compatibility checked API surface diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt index 9212adeee4003..88425582c7d0c 100644 --- a/compose/runtime/runtime/api/current.txt +++ b/compose/runtime/runtime/api/current.txt @@ -105,6 +105,12 @@ package androidx.compose.runtime { method public void onReuse(); } + @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeApi public final class ComposeRuntimeFlags { + property public final boolean isMovingNestedMovableContentEnabled; + field public static final androidx.compose.runtime.ComposeRuntimeFlags INSTANCE; + field public static boolean isMovingNestedMovableContentEnabled; + } + public sealed interface Composer { method @androidx.compose.runtime.ComposeCompilerApi public void apply(V value, kotlin.jvm.functions.Function2 block); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public androidx.compose.runtime.CompositionContext buildContext(); @@ -171,18 +177,18 @@ package androidx.compose.runtime { method @androidx.compose.runtime.ComposeCompilerApi public void startReusableNode(); method @androidx.compose.runtime.ComposeCompilerApi public void updateRememberedValue(Object? value); method @androidx.compose.runtime.ComposeCompilerApi public void useNode(); - property public abstract androidx.compose.runtime.Applier applier; + property @androidx.compose.runtime.ComposeCompilerApi public abstract androidx.compose.runtime.Applier applier; property @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi @org.jetbrains.annotations.TestOnly public abstract kotlin.coroutines.CoroutineContext applyCoroutineContext; property @org.jetbrains.annotations.TestOnly public abstract androidx.compose.runtime.ControlledComposition composition; property public abstract androidx.compose.runtime.tooling.CompositionData compositionData; - property public abstract int compoundKeyHash; + property @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public abstract int compoundKeyHash; property public abstract androidx.compose.runtime.CompositionLocalMap currentCompositionLocalMap; - property public abstract int currentMarker; - property public abstract boolean defaultsInvalid; - property public abstract boolean inserting; - property public abstract androidx.compose.runtime.RecomposeScope? recomposeScope; - property public abstract Object? recomposeScopeIdentity; - property public abstract boolean skipping; + property @androidx.compose.runtime.ComposeCompilerApi public abstract int currentMarker; + property @androidx.compose.runtime.ComposeCompilerApi public abstract boolean defaultsInvalid; + property @androidx.compose.runtime.ComposeCompilerApi public abstract boolean inserting; + property @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public abstract androidx.compose.runtime.RecomposeScope? recomposeScope; + property @androidx.compose.runtime.ComposeCompilerApi public abstract Object? recomposeScopeIdentity; + property @androidx.compose.runtime.ComposeCompilerApi public abstract boolean skipping; field public static final androidx.compose.runtime.Composer.Companion Companion; } @@ -233,6 +239,7 @@ package androidx.compose.runtime { public interface CompositionLocalAccessorScope { method public T getCurrentValue(androidx.compose.runtime.CompositionLocal); + property public T currentValue; } @androidx.compose.runtime.Stable public final class CompositionLocalContext { @@ -278,6 +285,7 @@ package androidx.compose.runtime { method public void composeContent(kotlin.jvm.functions.Function0 content); method public R delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0 block); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState state); + method public androidx.compose.runtime.ShouldPauseCallback? getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback? shouldPause); method public boolean getHasPendingChanges(); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(java.util.List> references); method public void invalidateAll(); @@ -288,7 +296,6 @@ package androidx.compose.runtime { method public void recordModificationsOf(java.util.Set values); method public void recordReadOf(Object value); method public void recordWriteOf(Object value); - method public kotlin.jvm.functions.Function0? setShouldPauseCallback(kotlin.jvm.functions.Function0? shouldPause); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void verifyConsistent(); property public abstract boolean hasPendingChanges; property public abstract boolean isComposing; @@ -462,7 +469,7 @@ package androidx.compose.runtime { @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY_GETTER}) public @interface NonSkippableComposable { } - public interface PausableComposition extends androidx.compose.runtime.ReusableComposition { + public sealed interface PausableComposition extends androidx.compose.runtime.ReusableComposition { method public androidx.compose.runtime.PausedComposition setPausableContent(kotlin.jvm.functions.Function0 content); method public androidx.compose.runtime.PausedComposition setPausableContentWithReuse(kotlin.jvm.functions.Function0 content); } @@ -480,11 +487,11 @@ package androidx.compose.runtime { property public final boolean isPaused; } - public interface PausedComposition { + public sealed interface PausedComposition { method public void apply(); method public void cancel(); method public boolean isComplete(); - method public boolean resume(kotlin.jvm.functions.Function0 shouldPause); + method public boolean resume(androidx.compose.runtime.ShouldPauseCallback shouldPause); property public abstract boolean isComplete; } @@ -586,6 +593,10 @@ package androidx.compose.runtime { method public void updateScope(kotlin.jvm.functions.Function2 block); } + public fun interface ShouldPauseCallback { + method public boolean shouldPause(); + } + @kotlin.jvm.JvmInline public final value class SkippableUpdater { ctor public SkippableUpdater(@kotlin.PublishedApi androidx.compose.runtime.Composer composer); method public inline void update(kotlin.jvm.functions.Function1,kotlin.Unit> block); @@ -692,7 +703,7 @@ package androidx.compose.runtime.collection { method public boolean containsAll(java.util.Collection elements); method public boolean containsAll(java.util.List elements); method public boolean contentEquals(androidx.compose.runtime.collection.MutableVector other); - method public void ensureCapacity(int capacity); + method public inline void ensureCapacity(int capacity); method public T first(); method public inline T first(kotlin.jvm.functions.Function1 predicate); method public inline T? firstOrNull(); @@ -800,6 +811,9 @@ package androidx.compose.runtime.internal { @androidx.compose.runtime.ComposeCompilerApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.RUNTIME) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public static @interface FunctionKeyMeta.Container { method public abstract androidx.compose.runtime.internal.FunctionKeyMeta[] value(); + property public abstract int endOffset; + property public abstract int key; + property public abstract int startOffset; } @androidx.compose.runtime.ComposeCompilerApi @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.RUNTIME) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface FunctionKeyMetaClass { @@ -824,7 +838,7 @@ package androidx.compose.runtime.internal { method public static boolean isLiveLiteralsEnabled(); method @SuppressCompatibility @androidx.compose.runtime.ComposeCompilerApi @androidx.compose.runtime.InternalComposeApi public static androidx.compose.runtime.State liveLiteral(String key, T value); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public static void updateLiveLiteralValue(String key, Object? value); - property public static final boolean isLiveLiteralsEnabled; + property @SuppressCompatibility @androidx.compose.runtime.ComposeCompilerApi @androidx.compose.runtime.InternalComposeApi public static final boolean isLiveLiteralsEnabled; } @androidx.compose.runtime.ComposeCompilerApi @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface StabilityInferred { @@ -883,16 +897,18 @@ package androidx.compose.runtime.snapshots { public abstract sealed class Snapshot { method public void dispose(); method public final inline T enter(kotlin.jvm.functions.Function0 block); - method public int getId(); + method @Deprecated public int getId(); method public abstract boolean getReadOnly(); method public abstract androidx.compose.runtime.snapshots.Snapshot getRoot(); + method public long getSnapshotId(); method public abstract boolean hasPendingChanges(); method public abstract androidx.compose.runtime.snapshots.Snapshot takeNestedSnapshot(optional kotlin.jvm.functions.Function1? readObserver); method public final androidx.compose.runtime.snapshots.Snapshot? unsafeEnter(); method public final void unsafeLeave(androidx.compose.runtime.snapshots.Snapshot? oldSnapshot); - property public int id; + property @Deprecated public int id; property public abstract boolean readOnly; property public abstract androidx.compose.runtime.snapshots.Snapshot root; + property public long snapshotId; field public static final androidx.compose.runtime.snapshots.Snapshot.Companion Companion; field public static final int PreexistingSnapshotId = 1; // 0x1 } @@ -912,6 +928,7 @@ package androidx.compose.runtime.snapshots { method public androidx.compose.runtime.snapshots.Snapshot takeSnapshot(optional kotlin.jvm.functions.Function1? readObserver); method public inline R withMutableSnapshot(kotlin.jvm.functions.Function0 block); method public inline T withoutReadObservation(kotlin.jvm.functions.Function0 block); + property public static final int PreexistingSnapshotId; property public final androidx.compose.runtime.snapshots.Snapshot current; property public final boolean isApplyObserverNotificationPending; property public final boolean isInSnapshot; @@ -956,6 +973,25 @@ package androidx.compose.runtime.snapshots { method public static androidx.compose.runtime.snapshots.SnapshotContextElement asContextElement(androidx.compose.runtime.snapshots.Snapshot); } + public final class SnapshotId_jvmKt { + method public static inline operator int compareTo(long, int other); + method public static inline operator int compareTo(long, long other); + method public static inline operator long div(long, int other); + method public static inline operator long minus(long, int other); + method public static inline operator long minus(long, long other); + method public static inline operator long plus(long, int other); + method public static inline operator long times(long, int other); + method public static inline int toInt(long); + property public static final long SnapshotIdInvalidValue; + property public static final long SnapshotIdMax; + property public static final int SnapshotIdSize; + property public static final long SnapshotIdZero; + field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL + field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL + field public static final int SnapshotIdSize = 64; // 0x40 + field public static final long SnapshotIdZero = 0L; // 0x0L + } + public final class SnapshotKt { method public static T readable(T, androidx.compose.runtime.snapshots.StateObject state); method public static T readable(T, androidx.compose.runtime.snapshots.StateObject state, androidx.compose.runtime.snapshots.Snapshot snapshot); @@ -1072,8 +1108,12 @@ package androidx.compose.runtime.snapshots { public abstract class StateRecord { ctor public StateRecord(); + ctor @Deprecated public StateRecord(int id); + ctor public StateRecord(long snapshotId); method public abstract void assign(androidx.compose.runtime.snapshots.StateRecord value); method public abstract androidx.compose.runtime.snapshots.StateRecord create(); + method @Deprecated public androidx.compose.runtime.snapshots.StateRecord create(int snapshotId); + method public androidx.compose.runtime.snapshots.StateRecord create(long snapshotId); } } @@ -1140,9 +1180,15 @@ package androidx.compose.runtime.tooling { public final class CompositionObserverKt { method @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public static androidx.compose.runtime.tooling.CompositionObserverHandle? observe(androidx.compose.runtime.Composition, androidx.compose.runtime.tooling.CompositionObserver observer); + method @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public static androidx.compose.runtime.tooling.CompositionObserverHandle observe(androidx.compose.runtime.Recomposer, androidx.compose.runtime.tooling.CompositionRegistrationObserver observer); method @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public static androidx.compose.runtime.tooling.CompositionObserverHandle observe(androidx.compose.runtime.RecomposeScope, androidx.compose.runtime.tooling.RecomposeScopeObserver observer); } + @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public interface CompositionRegistrationObserver { + method public void onCompositionRegistered(androidx.compose.runtime.Recomposer recomposer, androidx.compose.runtime.Composition composition); + method public void onCompositionUnregistered(androidx.compose.runtime.Recomposer recomposer, androidx.compose.runtime.Composition composition); + } + public final class InspectionTablesKt { method public static androidx.compose.runtime.ProvidableCompositionLocal?> getLocalInspectionTables(); property public static final androidx.compose.runtime.ProvidableCompositionLocal?> LocalInspectionTables; diff --git a/compose/runtime/runtime/api/desktop/runtime.api b/compose/runtime/runtime/api/desktop/runtime.api index 291c059e1639f..a24f0d18e26a1 100644 --- a/compose/runtime/runtime/api/desktop/runtime.api +++ b/compose/runtime/runtime/api/desktop/runtime.api @@ -18,7 +18,7 @@ public final class androidx/compose/runtime/ActualDesktop_desktopKt { public final class androidx/compose/runtime/ActualJvm_jvmKt { public static final fun identityHashCode (Ljava/lang/Object;)I - public static final fun synchronized (Landroidx/compose/runtime/SynchronizedObject;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; + public static final synthetic fun synchronized (Landroidx/compose/runtime/SynchronizedObject;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; } public abstract interface class androidx/compose/runtime/Applier { @@ -904,6 +904,10 @@ public abstract interface annotation class androidx/compose/runtime/internal/Sta public abstract fun parameters ()I } +public final class androidx/compose/runtime/platform/Synchronization_desktopKt { + public static final fun synchronized (Landroidx/compose/runtime/SynchronizedObject;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; +} + public final class androidx/compose/runtime/reflect/ComposableMethod { public static final field $stable I public final fun asMethod ()Ljava/lang/reflect/Method; diff --git a/compose/runtime/runtime/api/restricted_current.ignore b/compose/runtime/runtime/api/restricted_current.ignore index f02ef5a09be85..75715a1896bcc 100644 --- a/compose/runtime/runtime/api/restricted_current.ignore +++ b/compose/runtime/runtime/api/restricted_current.ignore @@ -1,3 +1,11 @@ // Baseline format: 1.0 -AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#setShouldPauseCallback(kotlin.jvm.functions.Function0): - Added method androidx.compose.runtime.ControlledComposition.setShouldPauseCallback(kotlin.jvm.functions.Function0) +AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback): + Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback) + + +BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash: + Removed property Composer.compoundKeyHash from compatibility checked API surface +BecameUnchecked: androidx.compose.runtime.Composer#recomposeScope: + Removed property Composer.recomposeScope from compatibility checked API surface +BecameUnchecked: androidx.compose.runtime.internal.LiveLiteralKt#isLiveLiteralsEnabled: + Removed property LiveLiteralKt.isLiveLiteralsEnabled from compatibility checked API surface diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt index 9580ec32e9f4d..45e66b0fe9f69 100644 --- a/compose/runtime/runtime/api/restricted_current.txt +++ b/compose/runtime/runtime/api/restricted_current.txt @@ -110,6 +110,12 @@ package androidx.compose.runtime { method public void onReuse(); } + @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeApi public final class ComposeRuntimeFlags { + property public final boolean isMovingNestedMovableContentEnabled; + field public static final androidx.compose.runtime.ComposeRuntimeFlags INSTANCE; + field public static boolean isMovingNestedMovableContentEnabled; + } + public sealed interface Composer { method @androidx.compose.runtime.ComposeCompilerApi public void apply(V value, kotlin.jvm.functions.Function2 block); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public androidx.compose.runtime.CompositionContext buildContext(); @@ -176,18 +182,18 @@ package androidx.compose.runtime { method @androidx.compose.runtime.ComposeCompilerApi public void startReusableNode(); method @androidx.compose.runtime.ComposeCompilerApi public void updateRememberedValue(Object? value); method @androidx.compose.runtime.ComposeCompilerApi public void useNode(); - property public abstract androidx.compose.runtime.Applier applier; + property @androidx.compose.runtime.ComposeCompilerApi public abstract androidx.compose.runtime.Applier applier; property @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi @org.jetbrains.annotations.TestOnly public abstract kotlin.coroutines.CoroutineContext applyCoroutineContext; property @org.jetbrains.annotations.TestOnly public abstract androidx.compose.runtime.ControlledComposition composition; property public abstract androidx.compose.runtime.tooling.CompositionData compositionData; - property public abstract int compoundKeyHash; + property @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public abstract int compoundKeyHash; property public abstract androidx.compose.runtime.CompositionLocalMap currentCompositionLocalMap; - property public abstract int currentMarker; - property public abstract boolean defaultsInvalid; - property public abstract boolean inserting; - property public abstract androidx.compose.runtime.RecomposeScope? recomposeScope; - property public abstract Object? recomposeScopeIdentity; - property public abstract boolean skipping; + property @androidx.compose.runtime.ComposeCompilerApi public abstract int currentMarker; + property @androidx.compose.runtime.ComposeCompilerApi public abstract boolean defaultsInvalid; + property @androidx.compose.runtime.ComposeCompilerApi public abstract boolean inserting; + property @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public abstract androidx.compose.runtime.RecomposeScope? recomposeScope; + property @androidx.compose.runtime.ComposeCompilerApi public abstract Object? recomposeScopeIdentity; + property @androidx.compose.runtime.ComposeCompilerApi public abstract boolean skipping; field public static final androidx.compose.runtime.Composer.Companion Companion; } @@ -206,6 +212,19 @@ package androidx.compose.runtime { method @androidx.compose.runtime.ComposeCompilerApi public static void traceEventEnd(); method @androidx.compose.runtime.ComposeCompilerApi public static void traceEventStart(int key, int dirty1, int dirty2, String info); method @Deprecated @androidx.compose.runtime.ComposeCompilerApi public static void traceEventStart(int key, String info); + property @kotlin.PublishedApi internal static final Object compositionLocalMap; + property @kotlin.PublishedApi internal static final int compositionLocalMapKey; + property @kotlin.PublishedApi internal static final Object invocation; + property @kotlin.PublishedApi internal static final int invocationKey; + property @kotlin.PublishedApi internal static final Object provider; + property @kotlin.PublishedApi internal static final int providerKey; + property @kotlin.PublishedApi internal static final Object providerMaps; + property @kotlin.PublishedApi internal static final int providerMapsKey; + property @kotlin.PublishedApi internal static final Object providerValues; + property @kotlin.PublishedApi internal static final int providerValuesKey; + property @kotlin.PublishedApi internal static final Object reference; + property @kotlin.PublishedApi internal static final int referenceKey; + property @kotlin.PublishedApi internal static final int reuseKey; field @kotlin.PublishedApi internal static final Object compositionLocalMap; field @kotlin.PublishedApi internal static final int compositionLocalMapKey = 202; // 0xca field @kotlin.PublishedApi internal static final Object invocation; @@ -251,6 +270,7 @@ package androidx.compose.runtime { public interface CompositionLocalAccessorScope { method public T getCurrentValue(androidx.compose.runtime.CompositionLocal); + property public T currentValue; } @androidx.compose.runtime.Stable public final class CompositionLocalContext { @@ -305,6 +325,7 @@ package androidx.compose.runtime { method public void composeContent(kotlin.jvm.functions.Function0 content); method public R delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0 block); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState state); + method public androidx.compose.runtime.ShouldPauseCallback? getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback? shouldPause); method public boolean getHasPendingChanges(); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(java.util.List> references); method public void invalidateAll(); @@ -315,7 +336,6 @@ package androidx.compose.runtime { method public void recordModificationsOf(java.util.Set values); method public void recordReadOf(Object value); method public void recordWriteOf(Object value); - method public kotlin.jvm.functions.Function0? setShouldPauseCallback(kotlin.jvm.functions.Function0? shouldPause); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void verifyConsistent(); property public abstract boolean hasPendingChanges; property public abstract boolean isComposing; @@ -490,7 +510,7 @@ package androidx.compose.runtime { @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY_GETTER}) public @interface NonSkippableComposable { } - public interface PausableComposition extends androidx.compose.runtime.ReusableComposition { + public sealed interface PausableComposition extends androidx.compose.runtime.ReusableComposition { method public androidx.compose.runtime.PausedComposition setPausableContent(kotlin.jvm.functions.Function0 content); method public androidx.compose.runtime.PausedComposition setPausableContentWithReuse(kotlin.jvm.functions.Function0 content); } @@ -508,11 +528,11 @@ package androidx.compose.runtime { property public final boolean isPaused; } - public interface PausedComposition { + public sealed interface PausedComposition { method public void apply(); method public void cancel(); method public boolean isComplete(); - method public boolean resume(kotlin.jvm.functions.Function0 shouldPause); + method public boolean resume(androidx.compose.runtime.ShouldPauseCallback shouldPause); property public abstract boolean isComplete; } @@ -618,9 +638,14 @@ package androidx.compose.runtime { method public void updateScope(kotlin.jvm.functions.Function2 block); } + public fun interface ShouldPauseCallback { + method public boolean shouldPause(); + } + @kotlin.jvm.JvmInline public final value class SkippableUpdater { ctor public SkippableUpdater(@kotlin.PublishedApi androidx.compose.runtime.Composer composer); method public inline void update(kotlin.jvm.functions.Function1,kotlin.Unit> block); + property @kotlin.PublishedApi internal final androidx.compose.runtime.Composer composer; } public final class SnapshotDoubleStateKt { @@ -700,6 +725,7 @@ package androidx.compose.runtime { method public void set(V value, kotlin.jvm.functions.Function2 block); method public inline void update(int value, kotlin.jvm.functions.Function2 block); method public void update(V value, kotlin.jvm.functions.Function2 block); + property @kotlin.PublishedApi internal final androidx.compose.runtime.Composer composer; } } @@ -725,7 +751,7 @@ package androidx.compose.runtime.collection { method public boolean containsAll(java.util.Collection elements); method public boolean containsAll(java.util.List elements); method public boolean contentEquals(androidx.compose.runtime.collection.MutableVector other); - method public void ensureCapacity(int capacity); + method public inline void ensureCapacity(int capacity); method public T first(); method public inline T first(kotlin.jvm.functions.Function1 predicate); method public inline T? firstOrNull(); @@ -739,6 +765,7 @@ package androidx.compose.runtime.collection { method public inline void forEachReversed(kotlin.jvm.functions.Function1 block); method public inline void forEachReversedIndexed(kotlin.jvm.functions.Function2 block); method public inline operator T get(int index); + method @kotlin.PublishedApi internal T?[] getContent(); method public inline kotlin.ranges.IntRange getIndices(); method public inline int getLastIndex(); method public int getSize(); @@ -765,6 +792,7 @@ package androidx.compose.runtime.collection { method public T removeAt(int index); method public inline void removeIf(kotlin.jvm.functions.Function1 predicate); method public void removeRange(int start, int end); + method @kotlin.PublishedApi internal void resizeStorage(int capacity); method public boolean retainAll(java.util.Collection elements); method public inline boolean reversedAny(kotlin.jvm.functions.Function1 predicate); method public operator T set(int index, T element); @@ -773,6 +801,7 @@ package androidx.compose.runtime.collection { method public inline int sumBy(kotlin.jvm.functions.Function1 selector); method @kotlin.PublishedApi internal inline Void throwNoSuchElementException(); method @kotlin.PublishedApi internal Void throwNoSuchElementException(String message); + property @kotlin.PublishedApi internal final T?[] content; property public final inline kotlin.ranges.IntRange indices; property public final inline int lastIndex; property public final int size; @@ -836,6 +865,9 @@ package androidx.compose.runtime.internal { @androidx.compose.runtime.ComposeCompilerApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.RUNTIME) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public static @interface FunctionKeyMeta.Container { method public abstract androidx.compose.runtime.internal.FunctionKeyMeta[] value(); + property public abstract int endOffset; + property public abstract int key; + property public abstract int startOffset; } @androidx.compose.runtime.ComposeCompilerApi @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.RUNTIME) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface FunctionKeyMetaClass { @@ -860,7 +892,7 @@ package androidx.compose.runtime.internal { method public static boolean isLiveLiteralsEnabled(); method @SuppressCompatibility @androidx.compose.runtime.ComposeCompilerApi @androidx.compose.runtime.InternalComposeApi public static androidx.compose.runtime.State liveLiteral(String key, T value); method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public static void updateLiveLiteralValue(String key, Object? value); - property public static final boolean isLiveLiteralsEnabled; + property @SuppressCompatibility @androidx.compose.runtime.ComposeCompilerApi @androidx.compose.runtime.InternalComposeApi public static final boolean isLiveLiteralsEnabled; } @androidx.compose.runtime.ComposeCompilerApi @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface StabilityInferred { @@ -919,18 +951,21 @@ package androidx.compose.runtime.snapshots { public abstract sealed class Snapshot { method public void dispose(); method public final inline T enter(kotlin.jvm.functions.Function0 block); - method public int getId(); + method @Deprecated public int getId(); method public abstract boolean getReadOnly(); method public abstract androidx.compose.runtime.snapshots.Snapshot getRoot(); + method public long getSnapshotId(); method public abstract boolean hasPendingChanges(); method @kotlin.PublishedApi internal androidx.compose.runtime.snapshots.Snapshot? makeCurrent(); method @kotlin.PublishedApi internal void restoreCurrent(androidx.compose.runtime.snapshots.Snapshot? snapshot); method public abstract androidx.compose.runtime.snapshots.Snapshot takeNestedSnapshot(optional kotlin.jvm.functions.Function1? readObserver); method public final androidx.compose.runtime.snapshots.Snapshot? unsafeEnter(); method public final void unsafeLeave(androidx.compose.runtime.snapshots.Snapshot? oldSnapshot); - property public int id; + property @Deprecated public int id; + property @kotlin.PublishedApi internal abstract kotlin.jvm.functions.Function1? readObserver; property public abstract boolean readOnly; property public abstract androidx.compose.runtime.snapshots.Snapshot root; + property public long snapshotId; field public static final androidx.compose.runtime.snapshots.Snapshot.Companion Companion; field public static final int PreexistingSnapshotId = 1; // 0x1 } @@ -955,6 +990,7 @@ package androidx.compose.runtime.snapshots { method public androidx.compose.runtime.snapshots.Snapshot takeSnapshot(optional kotlin.jvm.functions.Function1? readObserver); method public inline R withMutableSnapshot(kotlin.jvm.functions.Function0 block); method public inline T withoutReadObservation(kotlin.jvm.functions.Function0 block); + property public static final int PreexistingSnapshotId; property public final androidx.compose.runtime.snapshots.Snapshot current; property @kotlin.PublishedApi internal final androidx.compose.runtime.snapshots.Snapshot? currentThreadSnapshot; property public final boolean isApplyObserverNotificationPending; @@ -1000,6 +1036,25 @@ package androidx.compose.runtime.snapshots { method public static androidx.compose.runtime.snapshots.SnapshotContextElement asContextElement(androidx.compose.runtime.snapshots.Snapshot); } + public final class SnapshotId_jvmKt { + method public static inline operator int compareTo(long, int other); + method public static inline operator int compareTo(long, long other); + method public static inline operator long div(long, int other); + method public static inline operator long minus(long, int other); + method public static inline operator long minus(long, long other); + method public static inline operator long plus(long, int other); + method public static inline operator long times(long, int other); + method public static inline int toInt(long); + property public static final long SnapshotIdInvalidValue; + property public static final long SnapshotIdMax; + property public static final int SnapshotIdSize; + property public static final long SnapshotIdZero; + field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL + field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL + field public static final int SnapshotIdSize = 64; // 0x40 + field public static final long SnapshotIdZero = 0L; // 0x0L + } + public final class SnapshotKt { method @kotlin.PublishedApi internal static T current(T r); method @kotlin.PublishedApi internal static T current(T r, androidx.compose.runtime.snapshots.Snapshot snapshot); @@ -1011,6 +1066,8 @@ package androidx.compose.runtime.snapshots { method public static inline R writable(T, androidx.compose.runtime.snapshots.StateObject state, androidx.compose.runtime.snapshots.Snapshot snapshot, kotlin.jvm.functions.Function1 block); method public static inline R writable(T, androidx.compose.runtime.snapshots.StateObject state, kotlin.jvm.functions.Function1 block); method @kotlin.PublishedApi internal static T writableRecord(T, androidx.compose.runtime.snapshots.StateObject state, androidx.compose.runtime.snapshots.Snapshot snapshot); + property @kotlin.PublishedApi internal static final Object lock; + property @kotlin.PublishedApi internal static final androidx.compose.runtime.snapshots.Snapshot snapshotInitializer; field @kotlin.PublishedApi internal static final Object lock; field @kotlin.PublishedApi internal static final androidx.compose.runtime.snapshots.Snapshot snapshotInitializer; } @@ -1123,8 +1180,12 @@ package androidx.compose.runtime.snapshots { public abstract class StateRecord { ctor public StateRecord(); + ctor @Deprecated public StateRecord(int id); + ctor public StateRecord(long snapshotId); method public abstract void assign(androidx.compose.runtime.snapshots.StateRecord value); method public abstract androidx.compose.runtime.snapshots.StateRecord create(); + method @Deprecated public androidx.compose.runtime.snapshots.StateRecord create(int snapshotId); + method public androidx.compose.runtime.snapshots.StateRecord create(long snapshotId); } } @@ -1191,9 +1252,15 @@ package androidx.compose.runtime.tooling { public final class CompositionObserverKt { method @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public static androidx.compose.runtime.tooling.CompositionObserverHandle? observe(androidx.compose.runtime.Composition, androidx.compose.runtime.tooling.CompositionObserver observer); + method @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public static androidx.compose.runtime.tooling.CompositionObserverHandle observe(androidx.compose.runtime.Recomposer, androidx.compose.runtime.tooling.CompositionRegistrationObserver observer); method @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public static androidx.compose.runtime.tooling.CompositionObserverHandle observe(androidx.compose.runtime.RecomposeScope, androidx.compose.runtime.tooling.RecomposeScopeObserver observer); } + @SuppressCompatibility @androidx.compose.runtime.ExperimentalComposeRuntimeApi public interface CompositionRegistrationObserver { + method public void onCompositionRegistered(androidx.compose.runtime.Recomposer recomposer, androidx.compose.runtime.Composition composition); + method public void onCompositionUnregistered(androidx.compose.runtime.Recomposer recomposer, androidx.compose.runtime.Composition composition); + } + public final class InspectionTablesKt { method public static androidx.compose.runtime.ProvidableCompositionLocal?> getLocalInspectionTables(); property public static final androidx.compose.runtime.ProvidableCompositionLocal?> LocalInspectionTables; diff --git a/compose/runtime/runtime/build.gradle b/compose/runtime/runtime/build.gradle index e9bb7c02dbbf1..7dcd6cd86dd83 100644 --- a/compose/runtime/runtime/build.gradle +++ b/compose/runtime/runtime/build.gradle @@ -116,7 +116,6 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { commonMain.dependencies { implementation(libs.kotlinStdlibCommon) implementation(libs.kotlinCoroutinesCore) - implementation(libs.atomicFu) implementation(project(":annotation:annotation")) implementation(project(":collection:collection")) } @@ -149,7 +148,12 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { } nonAndroidMain.dependsOn(commonMain) - nonJvmMain.dependsOn(commonMain) + nonJvmMain { + dependsOn(commonMain) + dependencies { + implementation(libs.atomicFu) + } + } webMain.dependsOn(nonAndroidMain) webMain.dependsOn(nonJvmMain) diff --git a/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/RecomposerTests.kt b/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/RecomposerTests.kt index f91962a8e97ee..1de81b009a43f 100644 --- a/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/RecomposerTests.kt +++ b/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/RecomposerTests.kt @@ -114,9 +114,9 @@ class RecomposerTests : BaseComposeTest() { @Test fun testFrameTransition() { - var snapshotId: Int? = null - compose { snapshotId = Snapshot.current.id } - .then { assertNotSame(snapshotId, Snapshot.current.id) } + var snapshotId: Long? = null + compose { snapshotId = Snapshot.current.snapshotId } + .then { assertNotSame(snapshotId, Snapshot.current.snapshotId) } } @Test diff --git a/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/AndroidCompositionObserverTests.kt b/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/tooling/AndroidCompositionObserverTests.kt similarity index 84% rename from compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/AndroidCompositionObserverTests.kt rename to compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/tooling/AndroidCompositionObserverTests.kt index 48befe565720f..b618b04e875fa 100644 --- a/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/AndroidCompositionObserverTests.kt +++ b/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/tooling/AndroidCompositionObserverTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -14,11 +14,13 @@ * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.tooling -import androidx.compose.runtime.tooling.CompositionObserver -import androidx.compose.runtime.tooling.CompositionObserverHandle -import androidx.compose.runtime.tooling.observe +import androidx.compose.runtime.BaseComposeTest +import androidx.compose.runtime.Composition +import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.makeTestActivityRule import androidx.compose.ui.R import androidx.compose.ui.platform.LocalView import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/tooling/CompositionRegistrationObserverTest.kt b/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/tooling/CompositionRegistrationObserverTest.kt new file mode 100644 index 0000000000000..b92417cb85e0c --- /dev/null +++ b/compose/runtime/runtime/integration-tests/src/androidInstrumentedTest/kotlin/androidx/compose/runtime/tooling/CompositionRegistrationObserverTest.kt @@ -0,0 +1,600 @@ +/* + * 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. + */ + +package androidx.compose.runtime.tooling + +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@MediumTest +@OptIn(ExperimentalComposeRuntimeApi::class) +@RunWith(AndroidJUnit4::class) +class CompositionRegistrationObserverTest { + + @get:Rule val composeTestRule = createAndroidComposeRule() + + private lateinit var rootRecomposer: Recomposer + + @Test + fun testRecomposerNotifiesForInitialCompositions() = runTest { + val expectedCompositions = mutableSetOf() + setContent { + Row { + repeat(4) { subcomposition -> + SubcomposeLayout { constraints -> + val content = + subcompose(null) { + Text("Subcomposition $subcomposition") + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + .first() + .measure(constraints) + layout(content.width, content.height) { content.place(0, 0) } + } + } + } + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + + val receivedCompositions = mutableSetOf() + var disposed = false + val handle = + rootRecomposer.observe( + object : CompositionRegistrationObserver { + override fun onCompositionRegistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.add(composition), + "Attempted to register a duplicate composition" + ) + } + + override fun onCompositionUnregistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.remove(composition), + "Attempted to unregister an unknown composition" + ) + } + } + ) + + composeTestRule.awaitIdle() + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(5, expectedCompositions.size, "Got an unexpected number of compositions") + + disposed = true + handle.dispose() + } + + @Test + fun testRecomposerNotifiesForAddedSubcompositions() = runTest { + val expectedCompositions = mutableSetOf() + var subcompositionCount by mutableIntStateOf(0) + setContent { + Row { + repeat(subcompositionCount) { subcomposition -> + SubcomposeLayout { constraints -> + val content = + subcompose(null) { + Text("Subcomposition $subcomposition") + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + .first() + .measure(constraints) + layout(content.width, content.height) { content.place(0, 0) } + } + } + } + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + + val receivedCompositions = mutableSetOf() + var disposed = false + val handle = + rootRecomposer.observe( + object : CompositionRegistrationObserver { + override fun onCompositionRegistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.add(composition), + "Attempted to register a duplicate composition" + ) + } + + override fun onCompositionUnregistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.remove(composition), + "Attempted to unregister an unknown composition" + ) + } + } + ) + + composeTestRule.awaitIdle() + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(1, expectedCompositions.size, "Got an unexpected number of compositions") + + subcompositionCount = 12 + composeTestRule.awaitIdle() + + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(13, expectedCompositions.size, "Got an unexpected number of compositions") + + disposed = true + handle.dispose() + } + + @Test + fun testRecomposerNotifiesForRemovedSubcompositions() = runTest { + val expectedCompositions = mutableSetOf() + var subcompositionCount by mutableIntStateOf(12) + setContent { + Row { + repeat(subcompositionCount) { subcomposition -> + SubcomposeLayout { constraints -> + val content = + subcompose(null) { + Text("Subcomposition $subcomposition") + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + .first() + .measure(constraints) + layout(content.width, content.height) { content.place(0, 0) } + } + } + } + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + + val receivedCompositions = mutableSetOf() + var disposed = false + val handle = + rootRecomposer.observe( + object : CompositionRegistrationObserver { + override fun onCompositionRegistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.add(composition), + "Attempted to register a duplicate composition" + ) + } + + override fun onCompositionUnregistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.remove(composition), + "Attempted to unregister an unknown composition" + ) + } + } + ) + + composeTestRule.awaitIdle() + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(13, expectedCompositions.size, "Got an unexpected number of compositions") + + subcompositionCount = 3 + composeTestRule.awaitIdle() + + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(4, expectedCompositions.size, "Got an unexpected number of compositions") + + disposed = true + handle.dispose() + } + + @Test + fun testRecomposerNotifiesForAddedComposeView() = runTest { + val expectedCompositions = mutableSetOf() + var nestedInteropViewCount by mutableIntStateOf(0) + setContent { + Row { + repeat(nestedInteropViewCount) { view -> + AndroidView( + factory = { context -> + FrameLayout(context).apply { + addView( + ComposeView(context).apply { + setContent { + Text("Compose in AndroidView $view") + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + } + ) + } + } + ) + } + } + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + + val receivedCompositions = mutableSetOf() + var disposed = false + val handle = + rootRecomposer.observe( + object : CompositionRegistrationObserver { + override fun onCompositionRegistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.add(composition), + "Attempted to register a duplicate composition" + ) + } + + override fun onCompositionUnregistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.remove(composition), + "Attempted to unregister an unknown composition" + ) + } + } + ) + + composeTestRule.awaitIdle() + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(1, expectedCompositions.size, "Got an unexpected number of compositions") + + nestedInteropViewCount = 6 + composeTestRule.awaitIdle() + + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(7, expectedCompositions.size, "Got an unexpected number of compositions") + + disposed = true + handle.dispose() + } + + @Test + fun testRecomposerNotifiesForRemovedComposeView() = runTest { + val expectedCompositions = mutableSetOf() + var nestedInteropViewCount by mutableIntStateOf(8) + setContent { + Row { + repeat(nestedInteropViewCount) { view -> + AndroidView( + factory = { context -> + FrameLayout(context).apply { + addView( + ComposeView(context).apply { + setContent { + Text("Compose in AndroidView $view") + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + } + ) + } + } + ) + } + } + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + + val receivedCompositions = mutableSetOf() + var disposed = false + val handle = + rootRecomposer.observe( + object : CompositionRegistrationObserver { + override fun onCompositionRegistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.add(composition), + "Attempted to register a duplicate composition" + ) + } + + override fun onCompositionUnregistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.remove(composition), + "Attempted to unregister an unknown composition" + ) + } + } + ) + + composeTestRule.awaitIdle() + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(9, expectedCompositions.size, "Got an unexpected number of compositions") + + nestedInteropViewCount = 2 + composeTestRule.awaitIdle() + + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(3, expectedCompositions.size, "Got an unexpected number of compositions") + + disposed = true + handle.dispose() + } + + @Test + fun testRecomposerNotifiesForAddedDialog() = runTest { + val expectedCompositions = mutableSetOf() + var showDialog by mutableStateOf(false) + setContent { + if (showDialog) { + Dialog(onDismissRequest = { showDialog = false }) { + Text("Dialog") + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + } + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + + val receivedCompositions = mutableSetOf() + var disposed = false + val handle = + rootRecomposer.observe( + object : CompositionRegistrationObserver { + override fun onCompositionRegistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.add(composition), + "Attempted to register a duplicate composition" + ) + } + + override fun onCompositionUnregistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.remove(composition), + "Attempted to unregister an unknown composition" + ) + } + } + ) + + composeTestRule.awaitIdle() + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(1, expectedCompositions.size, "Got an unexpected number of compositions") + + showDialog = true + composeTestRule.awaitIdle() + + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(2, expectedCompositions.size, "Got an unexpected number of compositions") + + disposed = true + handle.dispose() + } + + @Test + fun testRecomposerNotifiesForRemovedDialog() = runTest { + val expectedCompositions = mutableSetOf() + var showDialog by mutableStateOf(true) + setContent { + if (showDialog) { + Dialog(onDismissRequest = { showDialog = false }) { + Text("Dialog") + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + } + + val composition = currentComposer.composition + DisposableEffect(composition) { + expectedCompositions += composition + onDispose { expectedCompositions -= composition } + } + } + + val receivedCompositions = mutableSetOf() + var disposed = false + val handle = + rootRecomposer.observe( + object : CompositionRegistrationObserver { + override fun onCompositionRegistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.add(composition), + "Attempted to register a duplicate composition" + ) + } + + override fun onCompositionUnregistered( + recomposer: Recomposer, + composition: Composition + ) { + assertFalse(disposed, "Callback invoked after being disposed") + assertSame(rootRecomposer, recomposer, "Unexpected Recomposer") + assertTrue( + receivedCompositions.remove(composition), + "Attempted to unregister an unknown composition" + ) + } + } + ) + + composeTestRule.awaitIdle() + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(2, expectedCompositions.size, "Got an unexpected number of compositions") + + showDialog = false + composeTestRule.awaitIdle() + + assertEquals(expectedCompositions, receivedCompositions, "Got unexpected compositions") + assertEquals(1, expectedCompositions.size, "Got an unexpected number of compositions") + + disposed = true + handle.dispose() + } + + private fun setContent(content: @Composable () -> Unit) { + composeTestRule.setContent { + content() + rootRecomposer = currentCompositionContext as Recomposer + } + } + + /** + * Workaround to get the Recomposer created by the Compose test rule, since we're not able to + * inject our own recomposer, and the recomposer created by the test rule isn't set in the view + * hierarchy. + */ + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + private val currentCompositionContext: CompositionContext + @Composable + get() { + val composition = currentComposer.composition + return (composition as androidx.compose.runtime.CompositionImpl).parent + } +} diff --git a/compose/runtime/runtime/proguard-rules.pro b/compose/runtime/runtime/proguard-rules.pro index f32c782b685ef..6e7819340ed68 100644 --- a/compose/runtime/runtime/proguard-rules.pro +++ b/compose/runtime/runtime/proguard-rules.pro @@ -20,7 +20,13 @@ -keep,allowshrinking,allowobfuscation class androidx.compose.runtime.** { # java.lang.Void == methods that return Nothing static void throw*Exception(...); + static void throw*ExceptionForNullCheck(...); static java.lang.Void throw*Exception(...); + static java.lang.Void throw*ExceptionForNullCheck(...); + + # For functions generating error messages + static java.lang.String exceptionMessage*(...); + java.lang.String exceptionMessage*(...); static void compose*RuntimeError(...); static java.lang.Void compose*RuntimeError(...); diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt index 5b336616bed29..b7eb115a980c8 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt @@ -196,18 +196,18 @@ interface Applier { * @see ComposeNode */ abstract class AbstractApplier(val root: T) : Applier { - private val stack = mutableListOf() + private val stack = Stack() + override var current: T = root protected set override fun down(node: T) { - stack.add(current) + stack.push(current) current = node } override fun up() { - checkPrecondition(stack.isNotEmpty()) { "empty stack" } - current = stack.removeAt(stack.size - 1) + current = stack.pop() } final override fun clear() { @@ -283,6 +283,6 @@ internal class OffsetApplier(private val applier: Applier, private val off } override fun clear() { - runtimeCheck(false) { "Clear is not valid on OffsetApplier" } + composeImmediateRuntimeError("Clear is not valid on OffsetApplier") } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/BroadcastFrameClock.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/BroadcastFrameClock.kt index 8e42d1adee2c7..61b7dff94d8eb 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/BroadcastFrameClock.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/BroadcastFrameClock.kt @@ -17,6 +17,8 @@ package androidx.compose.runtime import androidx.compose.runtime.internal.AtomicInt +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import androidx.compose.runtime.snapshots.fastForEach import kotlin.coroutines.Continuation import kotlin.coroutines.resumeWithException @@ -42,7 +44,7 @@ class BroadcastFrameClock(private val onNewAwaiters: (() -> Unit)? = null) : Mon } } - private val lock = SynchronizedObject() + private val lock = makeSynchronizedObject() private var failureCause: Throwable? = null private var awaiters = mutableListOf>() private var spareList = mutableListOf>() diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeRuntimeFlags.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeRuntimeFlags.kt new file mode 100644 index 0000000000000..8a8a25c1c0262 --- /dev/null +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeRuntimeFlags.kt @@ -0,0 +1,36 @@ +/* + * 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. + */ + +package androidx.compose.runtime + +import kotlin.jvm.JvmField + +@ExperimentalComposeApi +object ComposeRuntimeFlags { + /** + * A feature flag that can be used to disable detecting nested movable content. + * + * The way movable is detected was changed to ensure that movable content that is no longer + * used, but was nested in other unused movable content, is made a candidate for moving to avoid + * state being lost. However, this is a change in behavior may have indirectly been relied on by + * an application. This flags allows detecting if any regressions are caused by this change in + * behavior and provides a temporary work-around. + * + * This feature flag will eventually be depreciated and removed. All applications should be + * updated to ensure they are compatible with the new behavior. + */ + @JvmField @Suppress("MutableBareField") var isMovingNestedMovableContentEnabled = true +} diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt index bdde575b5a0a1..f3c0a635bd91c 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt @@ -24,13 +24,20 @@ import androidx.collection.MutableIntIntMap import androidx.collection.MutableIntObjectMap import androidx.collection.MutableScatterMap import androidx.collection.MutableScatterSet +import androidx.collection.ObjectList +import androidx.collection.ScatterMap import androidx.collection.ScatterSet +import androidx.collection.emptyScatterMap +import androidx.collection.mutableScatterMapOf import androidx.collection.mutableScatterSetOf import androidx.compose.runtime.Composer.Companion.equals import androidx.compose.runtime.changelist.ChangeList import androidx.compose.runtime.changelist.ComposerChangeListWriter import androidx.compose.runtime.changelist.FixupList +import androidx.compose.runtime.collection.MultiValueMap import androidx.compose.runtime.collection.ScopeMap +import androidx.compose.runtime.collection.fastFilter +import androidx.compose.runtime.collection.sortedBy import androidx.compose.runtime.internal.IntRef import androidx.compose.runtime.internal.invokeComposable import androidx.compose.runtime.internal.persistentCompositionLocalHashMapOf @@ -144,7 +151,7 @@ private class Pending(val keyInfos: MutableList, val startIndex: Int) { multiMap(keyInfos.size).also { for (index in 0 until keyInfos.size) { val keyInfo = keyInfos[index] - it.put(keyInfo.joinedKey, keyInfo) + it.add(keyInfo.joinedKey, keyInfo) } } } @@ -152,7 +159,7 @@ private class Pending(val keyInfos: MutableList, val startIndex: Int) { /** Get the next key information for the given key. */ fun getNext(key: Int, dataKey: Any?): KeyInfo? { val joinedKey: Any = if (dataKey != null) JoinedKey(key, dataKey) else key - return keyMap.pop(joinedKey) + return keyMap.removeFirst(joinedKey) } /** Record that this key info was generated. */ @@ -384,7 +391,8 @@ internal constructor( internal val slotTable: SlotTable, internal val anchor: Anchor, internal var invalidations: List>, - internal val locals: PersistentCompositionLocalMap + internal val locals: PersistentCompositionLocalMap, + internal val nestedReferences: List? ) /** @@ -395,7 +403,64 @@ internal constructor( * and before it is inserted during [ControlledComposition.insertMovableContent]. */ @InternalComposeApi -class MovableContentState internal constructor(internal val slotTable: SlotTable) +class MovableContentState internal constructor(internal val slotTable: SlotTable) { + + /** Extract one or more states for movable content that is nested in the [slotTable]. */ + internal fun extractNestedStates( + applier: Applier<*>, + references: ObjectList + ): ScatterMap { + // We can only remove states that are contained in this states slot table so the references + // with anchors not owned by the slotTable should be removed. We also should traverse the + // slot table in order to avoid thrashing the gap buffer so the references are sorted. + val referencesToExtract = + references + .fastFilter { slotTable.ownsAnchor(it.anchor) } + .sortedBy { slotTable.anchorIndex(it.anchor) } + if (referencesToExtract.isEmpty()) return emptyScatterMap() + val result = mutableScatterMapOf() + slotTable.write { writer -> + fun closeToGroupContaining(group: Int) { + while (writer.parent >= 0 && writer.currentGroupEnd <= group) { + writer.skipToGroupEnd() + writer.endGroup() + } + } + fun openParent(parent: Int) { + closeToGroupContaining(parent) + while (writer.currentGroup != parent && !writer.isGroupEnd) { + if (parent < writer.nextGroup) { + writer.startGroup() + } else { + writer.skipGroup() + } + } + runtimeCheck(writer.currentGroup == parent) { "Unexpected slot table structure" } + writer.startGroup() + } + referencesToExtract.forEach { reference -> + val newGroup = writer.anchorIndex(reference.anchor) + val newParent = writer.parent(newGroup) + closeToGroupContaining(newParent) + openParent(newParent) + writer.advanceBy(newGroup - writer.currentGroup) + val content = + extractMovableContentAtCurrent( + composition = reference.composition, + reference = reference, + slots = writer, + applier = applier, + ) + result[reference] = content + } + closeToGroupContaining(Int.MAX_VALUE) + } + return result + } +} + +private val SlotWriter.nextGroup + get() = currentGroup + groupSize(currentGroup) /** * Composer is the interface that is targeted by the Compose Kotlin compiler plugin and used by code @@ -993,6 +1058,18 @@ sealed interface Composer { * not execute if parameter has not changed and the nothing else is forcing the function to * execute (such as its scope was invalidated or a static composition local it was changed) or * the composition is pausable and the composition is pausing. + * + * @param parametersChanged `true` if the parameters to the composable function have changed. + * This is also `true` if the composition is [inserting] or if content is being reused. + * @param flags The `$changed` parameter that contains the forced recompose bit to allow the + * composer to disambiguate when the parameters changed due the execution being forced or if + * the parameters actually changed. This is only ambiguous in a [PausableComposition] and is + * necessary to determine if the function can be paused. The bits, other than 0, are reserved + * for future use (which would required the bit 31, which is unused in `$changed` values, to + * be set to indicate that the flags carry additional information). Passing the `$changed` + * flags directly, instead of masking the 0 bit, is more efficient as it allows less code to + * be generated per call to `shouldExecute` which is every called in every restartable + * function, as well as allowing for the API to be extended without a breaking changed. */ @InternalComposeApi fun shouldExecute(parametersChanged: Boolean, flags: Int): Boolean @@ -1366,7 +1443,7 @@ internal class ComposerImpl( private var insertFixups = FixupList() private var pausable: Boolean = false - private var shouldPauseCallback: (() -> Boolean)? = null + private var shouldPauseCallback: ShouldPauseCallback? = null override val applyCoroutineContext: CoroutineContext @TestOnly get() = parentContext.effectCoroutineContext @@ -2206,9 +2283,13 @@ internal class ComposerImpl( } else { val oldScope = reader.groupAux(reader.currentGroup) as PersistentCompositionLocalMap providers = - if ((!skipping || change) && (value.canOverride || !parentScope.contains(local))) - parentScope.putValue(local, state) - else oldScope + when { + (!skipping || change) && (value.canOverride || !parentScope.contains(local)) -> + parentScope.putValue(local, state) + !change && !providersInvalid -> oldScope + providersInvalid -> parentScope + else -> oldScope + } invalid = reusing || oldScope !== providers } if (invalid && !inserting) { @@ -2731,8 +2812,7 @@ internal class ComposerImpl( // Calculate the compound hash code (a semi-unique code for every group in the // composition used to restore saved state). val newParent = reader.parent(newGroup) - compoundKeyHash = - compoundKeyOf(newParent, rGroupIndexOf(newParent), parent, recomposeCompoundKey) + compoundKeyHash = compoundKeyOf(newParent, parent, recomposeCompoundKey) // We have moved so the cached lookup of the provider is invalid providerCache = null @@ -2957,32 +3037,50 @@ internal class ComposerImpl( * for [group]. Passing in the [recomposeGroup] and [recomposeKey] allows this method to exit * early. */ - private fun compoundKeyOf( - group: Int, - rGroupIndex: Int, - recomposeGroup: Int, - recomposeKey: Int - ): Int { - return if (group == recomposeGroup) recomposeKey - else - run { - val groupKey = reader.groupCompoundKeyPart(group) - if (groupKey == movableContentKey) groupKey - else { - val parent = reader.parent(group) - val parentKey = - if (parent == recomposeGroup) recomposeKey - else - compoundKeyOf( - parent, - rGroupIndexOf(parent), - recomposeGroup, - recomposeKey - ) - val effectiveRGroupIndex = if (reader.hasObjectKey(group)) 0 else rGroupIndex - (((parentKey rol 3) xor groupKey) rol 3) xor effectiveRGroupIndex - } + private fun compoundKeyOf(group: Int, recomposeGroup: Int, recomposeKey: Int): Int { + // The general form of a group's compoundKey can be solved by recursively evaluating: + // compoundKey(group) = ((compoundKey(parent(group)) rol 3) + // xor compoundKeyPart(group) rol 3) xor effectiveRGroupIndex + // + // To solve this without recursion, first expand the terms: + // compoundKey(group) = (compoundKey(parent(group)) rol 6) + // xor (compoundKeyPart(group) rol 3) + // xor effectiveRGroupIndex + // + // Then rewrite this as an iterative XOR sum, where n represents the distance from the + // starting node and takes the range 0 <= n < depth(group) and g - n represents the n-th + // parent of g, and all terms are XOR-ed together: + // + // [compoundKeyPart(g - n) rol (6n + 3)] xor [rGroupIndexOf(g - n) rol (6n)] + // + // Because compoundKey(g - n) is known when (g - n) == recomposeGroup, we can terminate + // early and substitute that iteration's terms with recomposeKey rol (6n). + + var keyRot = 3 + var rgiRot = 0 + var result = 0 + + var parent = group + while (parent >= 0) { + if (parent == recomposeGroup) { + result = result xor (recomposeKey rol rgiRot) + return result } + + val groupKey = reader.groupCompoundKeyPart(parent) + if (groupKey == movableContentKey) { + result = result xor (groupKey rol rgiRot) + return result + } + + result = result xor (groupKey rol keyRot) xor (rGroupIndexOf(parent) rol rgiRot) + keyRot = (keyRot + 6) % 32 + rgiRot = (rgiRot + 6) % 32 + + parent = reader.parent(parent) + } + + return result } private fun SlotReader.groupCompoundKeyPart(group: Int) = @@ -3061,7 +3159,7 @@ internal class ComposerImpl( if (((flags and 1) == 0) && (inserting || reusing)) { val callback = shouldPauseCallback ?: return true val scope = currentRecomposeScope ?: return true - val pausing = callback() + val pausing = callback.shouldPause() if (pausing) { scope.used = true // Force the composer back into the reusing state when this scope restarts. @@ -3263,7 +3361,8 @@ internal class ComposerImpl( insertTable, anchor, emptyList(), - currentCompositionLocalScope() + currentCompositionLocalScope(), + null ) parentContext.insertMovableContent(reference) } else { @@ -3493,7 +3592,7 @@ internal class ComposerImpl( internal fun composeContent( invalidationsRequested: ScopeMap, content: @Composable () -> Unit, - shouldPause: (() -> Boolean)? + shouldPause: ShouldPauseCallback? ) { runtimeCheck(changes.isEmpty()) { "Expected applyChanges() to have been called" } this.shouldPauseCallback = shouldPause @@ -3520,7 +3619,7 @@ internal class ComposerImpl( */ internal fun recompose( invalidationsRequested: ScopeMap, - shouldPause: (() -> Boolean)? + shouldPause: ShouldPauseCallback? ): Boolean { runtimeCheck(changes.isEmpty()) { "Expected applyChanges() to have been called" } // even if invalidationsRequested is empty we still need to recompose if the Composer has @@ -3556,7 +3655,7 @@ internal class ComposerImpl( ) { runtimeCheck(!isComposing) { "Reentrant composition is not supported" } trace("Compose:recompose") { - compositionToken = currentSnapshot().id + compositionToken = currentSnapshot().snapshotId.hashCode() providerUpdates = null updateComposerInvalidations(invalidationsRequested) nodeIndex = 0 @@ -3649,6 +3748,58 @@ internal class ComposerImpl( */ private fun reportFreeMovableContent(groupBeingRemoved: Int) { + fun createMovableContentReferenceForGroup( + group: Int, + nestedStates: List? + ): MovableContentStateReference { + @Suppress("UNCHECKED_CAST") + val movableContent = reader.groupObjectKey(group) as MovableContent + val parameter = reader.groupGet(group, 0) + val anchor = reader.anchor(group) + val end = group + reader.groupSize(group) + val invalidations = + this.invalidations.filterToRange(group, end).fastMap { it.scope to it.instances } + val reference = + MovableContentStateReference( + movableContent, + parameter, + composition, + slotTable, + anchor, + invalidations, + currentCompositionLocalScope(group), + nestedStates + ) + return reference + } + + fun movableContentReferenceFor(group: Int): MovableContentStateReference? { + val key = reader.groupKey(group) + val objectKey = reader.groupObjectKey(group) + return if (key == movableContentKey && objectKey is MovableContent<*>) { + val nestedStates = + if (reader.containsMark(group)) { + val nestedStates = mutableListOf() + fun traverseGroups(group: Int) { + val size = reader.groupSize(group) + val end = group + size + var current = group + 1 + while (current < end) { + if (reader.hasMark(current)) { + movableContentReferenceFor(current)?.let { + nestedStates.add(it) + } + } else if (reader.containsMark(current)) traverseGroups(current) + current += reader.groupSize(current) + } + } + traverseGroups(group) + nestedStates.takeIf { it.isNotEmpty() } + } else null + createMovableContentReferenceForGroup(group, nestedStates) + } else null + } + fun reportGroup(group: Int, needsNodeDelete: Boolean, nodeIndex: Int): Int { val reader = reader return if (reader.hasMark(group)) { @@ -3660,35 +3811,19 @@ internal class ComposerImpl( // If the group is a movable content block schedule it to be removed and report // that it is free to be moved to the parentContext. Nested movable content is // recomposed if necessary once the group has been claimed by another insert. - // If the nested movable content ends up being removed this is reported during - // that recomposition so there is no need to look at child movable content here. - @Suppress("UNCHECKED_CAST") - val movableContent = objectKey as MovableContent - val parameter = reader.groupGet(group, 0) - val anchor = reader.anchor(group) - val end = group + reader.groupSize(group) - val invalidations = - this.invalidations.filterToRange(group, end).fastMap { - it.scope to it.instances - } - val reference = - MovableContentStateReference( - movableContent, - parameter, + // reportMovableContentForGroup(group) + // reportMovableContentAt(group) + val reference = movableContentReferenceFor(group) + if (reference != null) { + parentContext.deletedMovableContent(reference) + changeListWriter.recordSlotEditing() + changeListWriter.releaseMovableGroupAtCurrent( composition, - slotTable, - anchor, - invalidations, - currentCompositionLocalScope(group) + parentContext, + reference ) - parentContext.deletedMovableContent(reference) - changeListWriter.recordSlotEditing() - changeListWriter.releaseMovableGroupAtCurrent( - composition, - parentContext, - reference - ) - if (needsNodeDelete) { + } + if (needsNodeDelete && group != groupBeingRemoved) { changeListWriter.endNodeMovementAndDeleteNode(nodeIndex, group) 0 // These nodes were deleted } else reader.nodeCount(group) @@ -3716,7 +3851,7 @@ internal class ComposerImpl( } else if (reader.containsMark(group)) { // Traverse the group freeing the child movable content. This group is known to // have at least one child that contains movable content because the group is - // marked as containing a mark. + // marked as containing a mark val size = reader.groupSize(group) val end = group + size var current = group + 1 @@ -3749,8 +3884,18 @@ internal class ComposerImpl( if (reader.isNode(group)) 1 else runningNodeCount } else if (reader.isNode(group)) 1 else reader.nodeCount(group) } - reportGroup(groupBeingRemoved, needsNodeDelete = false, nodeIndex = 0) + // If the group that is being deleted is a node we need to remove any children that + // are moved. + val rootIsNode = reader.isNode(groupBeingRemoved) + if (rootIsNode) { + changeListWriter.endNodeMovement() + changeListWriter.moveDown(reader.node(groupBeingRemoved)) + } + reportGroup(groupBeingRemoved, needsNodeDelete = rootIsNode, nodeIndex = 0) changeListWriter.endNodeMovement() + if (rootIsNode) { + changeListWriter.moveUp() + } } /** @@ -3874,14 +4019,14 @@ internal class ComposerImpl( override fun composeInitialPaused( composition: ControlledComposition, - shouldPause: () -> Boolean, + shouldPause: ShouldPauseCallback, content: @Composable () -> Unit ): ScatterSet = parentContext.composeInitialPaused(composition, shouldPause, content) override fun recomposePaused( composition: ControlledComposition, - shouldPause: () -> Boolean, + shouldPause: ShouldPauseCallback, invalidScopes: ScatterSet ): ScatterSet = parentContext.recomposePaused(composition, shouldPause, invalidScopes) @@ -3949,9 +4094,10 @@ internal class ComposerImpl( override fun movableContentStateReleased( reference: MovableContentStateReference, - data: MovableContentState + data: MovableContentState, + applier: Applier<*>, ) { - parentContext.movableContentStateReleased(reference, data) + parentContext.movableContentStateReleased(reference, data, applier) } override fun reportRemovedComposition(composition: ControlledComposition) { @@ -4238,51 +4384,8 @@ private fun SlotWriter.removeData(index: Int, data: Any?) { runtimeCheck(data === result) { "Slot table is out of sync (expected $data, got $result)" } } -@JvmInline -@Suppress("UNCHECKED_CAST") -private value class MutableScatterMultiMap(val map: MutableScatterMap) { - fun put(key: K, value: V) { - map.compute(key) { _, previous -> - when (previous) { - // If the key is new the value as store the value in the map - null -> value - - // If the value is a mutable list, then we already had duplicate, add it to the list - // This assumes that V is not itself a mutable list but this is safe as this private - // class is not instantiated with a MutableList as V. - is MutableList<*> -> { - val list = previous as MutableList - list.add(value) - list - } - - // This is the first duplicate, create a list to hold the duplicates - else -> mutableListOf(previous, value) - } - } - } - - fun pop(key: K) = - map[key]?.let { entry -> - @Suppress("UNCHECKED_CAST") - when (entry) { - is MutableList<*> -> { - val list = entry as MutableList - val result = list.removeAt(0) - if (list.isEmpty()) map.remove(key) - result - } - else -> { - map.remove(key) - entry - } - } - as V - } -} - -private fun multiMap(initialCapacity: Int) = - MutableScatterMultiMap(MutableScatterMap(initialCapacity)) +private fun multiMap(initialCapacity: Int) = + MultiValueMap(MutableScatterMap(initialCapacity)) private fun getKey(value: Any?, left: Any?, right: Any?): Any? = (value as? JoinedKey)?.let { @@ -4532,6 +4635,20 @@ internal inline fun runtimeCheck(value: Boolean, lazyMessage: () -> String) { } } +internal const val EnableDebugRuntimeChecks = false + +/** + * A variation of [composeRuntimeError] that gets stripped from R8-minified builds. Use this for + * more expensive checks or assertions along a hotpath that, if failed, would still lead to an + * application crash that could be traced back to this assertion if removed from the final program + * binary. + */ +internal inline fun debugRuntimeCheck(value: Boolean, lazyMessage: () -> String) { + if (EnableDebugRuntimeChecks && !value) { + composeImmediateRuntimeError(lazyMessage()) + } +} + internal fun runtimeCheck(value: Boolean) = runtimeCheck(value) { "Check failed" } internal fun composeRuntimeError(message: String): Nothing { @@ -4554,3 +4671,141 @@ internal fun composeImmediateRuntimeError(message: String) { private val InvalidationLocationAscending = Comparator { i1, i2 -> i1.location.compareTo(i2.location) } + +/** + * Extract the state of movable content from the given writer. A new slot table is created and the + * content is removed from [slots] (leaving a movable content group that, if composed over, will + * create new content) and added to this new slot table. The invalidations that occur to recompose + * scopes in the movable content state will be collected and forwarded to the new if the state is + * used. + */ +internal fun extractMovableContentAtCurrent( + composition: ControlledComposition, + reference: MovableContentStateReference, + slots: SlotWriter, + applier: Applier<*>?, +): MovableContentState { + val slotTable = SlotTable() + if (slots.collectingSourceInformation) { + slotTable.collectSourceInformation() + } + if (slots.collectingCalledInformation) { + slotTable.collectCalledByInformation() + } + + // If an applier is provided then we are extracting a state from the middle of an + // already extracted state. If the group has nodes then the nodes need to be removed + // from their parent so they can potentially be inserted into a destination. + val currentGroup = slots.currentGroup + if (applier != null && slots.nodeCount(currentGroup) > 0) { + @Suppress("UNCHECKED_CAST") + applier as Applier + + // Find the parent node by going up until the first node group + var parentNodeGroup = slots.parent + while (parentNodeGroup > 0 && !slots.isNode(parentNodeGroup)) { + parentNodeGroup = slots.parent(parentNodeGroup) + } + + // If we don't find a node group the nodes in the state have already been removed + // as they are the nodes that were removed when the state was removed from the original + // table. + if (parentNodeGroup >= 0 && slots.isNode(parentNodeGroup)) { + val node = slots.node(parentNodeGroup) + var currentChild = parentNodeGroup + 1 + val end = parentNodeGroup + slots.groupSize(parentNodeGroup) + + // Find the node index + var nodeIndex = 0 + while (currentChild < end) { + val size = slots.groupSize(currentChild) + if (currentChild + size > currentGroup) { + break + } + nodeIndex += if (slots.isNode(currentChild)) 1 else slots.nodeCount(currentChild) + currentChild += size + } + + // Remove the nodes + val count = if (slots.isNode(currentGroup)) 1 else slots.nodeCount(currentGroup) + applier.down(node) + applier.remove(nodeIndex, count) + applier.up() + } + } + + // Write a table that as if it was written by a calling invokeMovableContentLambda because this + // might be removed from the composition before the new composition can be composed to receive + // it. When the new composition receives the state it must recompose over the state by calling + // invokeMovableContentLambda. + val anchors = + slotTable.write { writer -> + writer.beginInsert() + + // This is the prefix created by invokeMovableContentLambda + writer.startGroup(movableContentKey, reference.content) + writer.markGroup() + writer.update(reference.parameter) + + // Move the content into current location + val anchors = slots.moveTo(reference.anchor, 1, writer) + + // skip the group that was just inserted. + writer.skipGroup() + + // End the group that represents the call to invokeMovableContentLambda + writer.endGroup() + + writer.endInsert() + + anchors + } + + val state = MovableContentState(slotTable) + if (RecomposeScopeImpl.hasAnchoredRecomposeScopes(slotTable, anchors)) { + // If any recompose scopes are invalidated while the movable content is outside a + // composition, ensure the reference is updated to contain the invalidation. + val movableContentRecomposeScopeOwner = + object : RecomposeScopeOwner { + override fun invalidate( + scope: RecomposeScopeImpl, + instance: Any? + ): InvalidationResult { + // Try sending this to the original owner first. + val result = + (composition as? RecomposeScopeOwner)?.invalidate(scope, instance) + ?: InvalidationResult.IGNORED + + // If the original owner ignores this then we need to record it in the + // reference + if (result == InvalidationResult.IGNORED) { + reference.invalidations += scope to instance + return InvalidationResult.SCHEDULED + } + return result + } + + // The only reason [recomposeScopeReleased] is called is when the recompose scope is + // removed from the table. First, this never happens for content that is moving, and + // 2) even if it did the only reason we tell the composer is to clear tracking + // tables that contain this information which is not relevant here. + override fun recomposeScopeReleased(scope: RecomposeScopeImpl) { + // Nothing to do + } + + // [recordReadOf] this is also something that would happen only during active + // recomposition which doesn't happened to a slot table that is moving. + override fun recordReadOf(value: Any) { + // Nothing to do + } + } + slotTable.write { writer -> + RecomposeScopeImpl.adoptAnchoredScopes( + slots = writer, + anchors = anchors, + newOwner = movableContentRecomposeScopeOwner + ) + } + } + return state +} diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt index f674dbe096ff2..454f9ad4a262c 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt @@ -25,6 +25,8 @@ import androidx.compose.runtime.collection.fastForEach import androidx.compose.runtime.internal.AtomicReference import androidx.compose.runtime.internal.RememberEventDispatcher import androidx.compose.runtime.internal.trace +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import androidx.compose.runtime.snapshots.ReaderKind import androidx.compose.runtime.snapshots.StateObjectImpl import androidx.compose.runtime.snapshots.fastAll @@ -297,19 +299,20 @@ sealed interface ControlledComposition : Composition { * longer needed. * @see PausableComposition */ - fun setShouldPauseCallback(shouldPause: (() -> Boolean)?): (() -> Boolean)? + @Suppress("ExecutorRegistration") + fun getAndSetShouldPauseCallback(shouldPause: ShouldPauseCallback?): ShouldPauseCallback? } /** Utility function to set and restore a should pause callback. */ internal inline fun ControlledComposition.pausable( - noinline shouldPause: () -> Boolean, + shouldPause: ShouldPauseCallback, block: () -> R ): R { - val previous = setShouldPauseCallback(shouldPause) + val previous = getAndSetShouldPauseCallback(shouldPause) return try { block() } finally { - setShouldPauseCallback(previous) + getAndSetShouldPauseCallback(previous) } } @@ -426,7 +429,7 @@ internal class CompositionImpl( * The parent composition from [rememberCompositionContext], for sub-compositions, or the an * instance of [Recomposer] for root compositions. */ - private val parent: CompositionContext, + @get:TestOnly val parent: CompositionContext, /** The applier to use to update the tree managed by the composition. */ private val applier: Applier<*>, @@ -446,7 +449,7 @@ internal class CompositionImpl( private val pendingModifications = AtomicReference(null) // Held when making changes to self or composer - private val lock = SynchronizedObject() + private val lock = makeSynchronizedObject() /** * A set of remember observers that were potentially abandoned between [composeContent] or @@ -551,7 +554,7 @@ internal class CompositionImpl( * If the [shouldPause] callback is set the composition is pausable and should pause whenever * the [shouldPause] callback returns `true`. */ - private var shouldPause: (() -> Boolean)? = null + private var shouldPause: ShouldPauseCallback? = null private var pendingPausedComposition: PausedCompositionImpl? = null @@ -1152,7 +1155,9 @@ internal class CompositionImpl( } else block() } - override fun setShouldPauseCallback(shouldPause: (() -> Boolean)?): (() -> Boolean)? { + override fun getAndSetShouldPauseCallback( + shouldPause: ShouldPauseCallback? + ): ShouldPauseCallback? { val previous = this.shouldPause this.shouldPause = shouldPause return previous diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt index e5b6d6b07e903..25b9431cfcc0a 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt @@ -55,13 +55,13 @@ abstract class CompositionContext internal constructor() { internal abstract fun composeInitialPaused( composition: ControlledComposition, - shouldPause: () -> Boolean, + shouldPause: ShouldPauseCallback, content: @Composable () -> Unit ): ScatterSet internal abstract fun recomposePaused( composition: ControlledComposition, - shouldPause: () -> Boolean, + shouldPause: ShouldPauseCallback, invalidScopes: ScatterSet ): ScatterSet @@ -94,7 +94,8 @@ abstract class CompositionContext internal constructor() { internal abstract fun movableContentStateReleased( reference: MovableContentStateReference, - data: MovableContentState + data: MovableContentState, + applier: Applier<*> ) internal open fun movableContentStateResolve( diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt index 499e7fdb7a5f1..5aff45e00234e 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt @@ -27,11 +27,14 @@ import androidx.compose.runtime.internal.IntRef import androidx.compose.runtime.internal.SnapshotThreadLocal import androidx.compose.runtime.internal.identityHashCode import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotId +import androidx.compose.runtime.snapshots.SnapshotIdZero import androidx.compose.runtime.snapshots.StateFactoryMarker import androidx.compose.runtime.snapshots.StateObject import androidx.compose.runtime.snapshots.StateObjectImpl import androidx.compose.runtime.snapshots.StateRecord import androidx.compose.runtime.snapshots.current +import androidx.compose.runtime.snapshots.currentSnapshot import androidx.compose.runtime.snapshots.newWritableRecord import androidx.compose.runtime.snapshots.sync import androidx.compose.runtime.snapshots.withCurrent @@ -84,14 +87,15 @@ private class DerivedSnapshotState( private val calculation: () -> T, override val policy: SnapshotMutationPolicy? ) : StateObjectImpl(), DerivedState { - private var first: ResultRecord = ResultRecord() + private var first: ResultRecord = ResultRecord(currentSnapshot().snapshotId) - class ResultRecord : StateRecord(), DerivedState.Record { + class ResultRecord(snapshotId: SnapshotId) : + StateRecord(snapshotId), DerivedState.Record { companion object { val Unset = Any() } - var validSnapshotId: Int = 0 + var validSnapshotId: SnapshotId = SnapshotIdZero var validSnapshotWriteCount: Int = 0 override var dependencies: ObjectIntMap = emptyObjectIntMap() @@ -105,11 +109,14 @@ private class DerivedSnapshotState( resultHash = other.resultHash } - override fun create(): StateRecord = ResultRecord() + override fun create(): StateRecord = create(currentSnapshot().snapshotId) + + override fun create(snapshotId: SnapshotId): StateRecord = ResultRecord(snapshotId) fun isValid(derivedState: DerivedState<*>, snapshot: Snapshot): Boolean { val snapshotChanged = sync { - validSnapshotId != snapshot.id || validSnapshotWriteCount != snapshot.writeCount + validSnapshotId != snapshot.snapshotId || + validSnapshotWriteCount != snapshot.writeCount } val isValid = result !== Unset && @@ -117,7 +124,7 @@ private class DerivedSnapshotState( if (isValid && snapshotChanged) { sync { - validSnapshotId = snapshot.id + validSnapshotId = snapshot.snapshotId validSnapshotWriteCount = snapshot.writeCount } } @@ -148,7 +155,7 @@ private class DerivedSnapshotState( } hash = 31 * hash + identityHashCode(record) - hash = 31 * hash + record.snapshotId + hash = 31 * hash + record.snapshotId.hashCode() } } } @@ -246,7 +253,7 @@ private class DerivedSnapshotState( sync { val currentSnapshot = Snapshot.current - record.validSnapshotId = currentSnapshot.id + record.validSnapshotId = currentSnapshot.snapshotId record.validSnapshotWriteCount = currentSnapshot.writeCount } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.kt index 10ce4193fb097..c55db08fc65ac 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Effects.kt @@ -17,6 +17,8 @@ package androidx.compose.runtime import androidx.compose.runtime.internal.PlatformOptimizedCancellationException +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import kotlin.concurrent.Volatile import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -426,7 +428,7 @@ internal class RememberedCoroutineScope( private val parentContext: CoroutineContext, private val overlayContext: CoroutineContext, ) : CoroutineScope, RememberObserver { - private val lock = SynchronizedObject() + private val lock = makeSynchronizedObject(this) // The goal of this implementation is to make cancellation as cheap as possible if the // coroutineContext property was never accessed, consisting only of taking a monitor lock and diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Latch.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Latch.kt index be2049861e988..df2bdf9972ebb 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Latch.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Latch.kt @@ -16,6 +16,8 @@ package androidx.compose.runtime +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine @@ -31,7 +33,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine */ internal class Latch { - private val lock = SynchronizedObject() + private val lock = makeSynchronizedObject() private var awaiters = mutableListOf>() private var spareList = mutableListOf>() diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt index 0eff8d6deb25b..3d420c567dd42 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt @@ -22,6 +22,8 @@ import androidx.collection.emptyScatterSet import androidx.collection.mutableIntListOf import androidx.collection.mutableObjectListOf import androidx.compose.runtime.internal.RememberEventDispatcher +import androidx.compose.runtime.platform.SynchronizedObject +import androidx.compose.runtime.platform.synchronized /** * A [PausableComposition] is a sub-composition that can be composed incrementally as it supports @@ -48,7 +50,7 @@ import androidx.compose.runtime.internal.RememberEventDispatcher * @see Composition * @see ReusableComposition */ -interface PausableComposition : ReusableComposition { +sealed interface PausableComposition : ReusableComposition { /** * Set the content of the composition. A [PausedComposition] that is currently paused. No * composition is performed until [PausedComposition.resume] is called. @@ -74,6 +76,17 @@ interface PausableComposition : ReusableComposition { fun setPausableContentWithReuse(content: @Composable () -> Unit): PausedComposition } +/** The callback type used in [PausedComposition.resume]. */ +fun interface ShouldPauseCallback { + /** + * Called to determine if a resumed [PausedComposition] should pause. + * + * @return Return `true` to indicate that the composition should pause. Otherwise the + * composition will continue normally. + */ + @Suppress("CallbackMethodName") fun shouldPause(): Boolean +} + /** * [PausedComposition] is the result of calling [PausableComposition.setContent] or * [PausableComposition.setContentWithReuse]. It is used to drive the paused composition to @@ -83,7 +96,7 @@ interface PausableComposition : ReusableComposition { * A [PausedComposition] is created paused and will only compose the `content` parameter when * [resume] is called the first time. */ -interface PausedComposition { +sealed interface PausedComposition { /** * Returns `true` when the [PausedComposition] is complete. [isComplete] matches the last value * returned from [resume]. Once a [PausedComposition] is [isComplete] the [apply] method should @@ -109,7 +122,7 @@ interface PausedComposition { * @return `true` if the composition is complete and `false` if one or more calls to `resume` * are required to complete composition. */ - fun resume(shouldPause: () -> Boolean): Boolean + @Suppress("ExecutorRegistration") fun resume(shouldPause: ShouldPauseCallback): Boolean /** * Apply the composition. This is the last step of a paused composition and is required to be @@ -164,7 +177,7 @@ internal class PausedCompositionImpl( override val isComplete: Boolean get() = state >= PausedCompositionState.ApplyPending - override fun resume(shouldPause: () -> Boolean): Boolean { + override fun resume(shouldPause: ShouldPauseCallback): Boolean { try { when (state) { PausedCompositionState.InitialPending -> { diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt index 48657d555c009..e27707206ecdf 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt @@ -19,6 +19,8 @@ package androidx.compose.runtime import androidx.collection.MutableObjectIntMap import androidx.collection.MutableScatterMap import androidx.collection.ScatterSet +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import androidx.compose.runtime.snapshots.fastAny import androidx.compose.runtime.snapshots.fastForEach import androidx.compose.runtime.tooling.CompositionObserverHandle @@ -74,7 +76,7 @@ internal interface RecomposeScopeOwner { fun recordReadOf(value: Any) } -private val callbackLock = SynchronizedObject() +private val callbackLock = makeSynchronizedObject() /** * A RecomposeScope is created for a region of the composition that can be recomposed independently diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt index e3470768b59e2..85852746a109d 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt @@ -16,11 +16,16 @@ package androidx.compose.runtime +import androidx.collection.MutableObjectList import androidx.collection.MutableScatterSet import androidx.collection.ScatterSet +import androidx.collection.emptyObjectList import androidx.collection.emptyScatterSet +import androidx.collection.mutableScatterMapOf import androidx.collection.mutableScatterSetOf +import androidx.compose.runtime.collection.MultiValueMap import androidx.compose.runtime.collection.fastForEach +import androidx.compose.runtime.collection.fastMap import androidx.compose.runtime.collection.mutableVectorOf import androidx.compose.runtime.collection.wrapIntoSet import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentSetOf @@ -28,6 +33,9 @@ import androidx.compose.runtime.internal.AtomicReference import androidx.compose.runtime.internal.SnapshotThreadLocal import androidx.compose.runtime.internal.logError import androidx.compose.runtime.internal.trace +import androidx.compose.runtime.platform.SynchronizedObject +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import androidx.compose.runtime.snapshots.MutableSnapshot import androidx.compose.runtime.snapshots.ReaderKind import androidx.compose.runtime.snapshots.Snapshot @@ -41,7 +49,8 @@ import androidx.compose.runtime.snapshots.fastGroupBy import androidx.compose.runtime.snapshots.fastMap import androidx.compose.runtime.snapshots.fastMapNotNull import androidx.compose.runtime.tooling.CompositionData -import kotlin.collections.removeFirst as removeFirstKt +import androidx.compose.runtime.tooling.CompositionObserverHandle +import androidx.compose.runtime.tooling.CompositionRegistrationObserver import kotlin.collections.removeLast as removeLastKt import kotlin.coroutines.Continuation import kotlin.coroutines.CoroutineContext @@ -199,7 +208,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( PendingWork } - private val stateLock = SynchronizedObject() + private val stateLock = makeSynchronizedObject() // Begin properties guarded by stateLock private var runnerJob: Job? = null @@ -220,11 +229,14 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( private var snapshotInvalidations = MutableScatterSet() private val compositionInvalidations = mutableVectorOf() private val compositionsAwaitingApply = mutableListOf() - private val compositionValuesAwaitingInsert = mutableListOf() - private val compositionValuesRemoved = - mutableMapOf, MutableList>() - private val compositionValueStatesAvailable = - mutableMapOf() + private val movableContentAwaitingInsert = mutableListOf() + private val movableContentRemoved = + MultiValueMap, MovableContentStateReference>() + private val movableContentNestedStatesAvailable = NestedContentMap() + private val movableContentStatesAvailable = + mutableScatterMapOf() + private val movableContentNestedExtractionsPending = + MultiValueMap() private var failedCompositions: MutableList? = null private var compositionsRemoved: MutableSet? = null private var workContinuation: CancellableContinuation? = null @@ -302,6 +314,9 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( private val hasBroadcastFrameClockAwaiters: Boolean get() = synchronized(stateLock) { hasBroadcastFrameClockAwaitersLocked } + @ExperimentalComposeRuntimeApi + private var registrationObservers: MutableObjectList? = null + /** * Determine the new value of [_state]. Call only while locked on [stateLock]. If it returns a * continuation, that continuation should be resumed after releasing the lock. @@ -312,7 +327,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( snapshotInvalidations = MutableScatterSet() compositionInvalidations.clear() compositionsAwaitingApply.clear() - compositionValuesAwaitingInsert.clear() + movableContentAwaitingInsert.clear() failedCompositions = null workContinuation?.cancel() workContinuation = null @@ -334,7 +349,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( compositionInvalidations.isNotEmpty() || snapshotInvalidations.isNotEmpty() || compositionsAwaitingApply.isNotEmpty() || - compositionValuesAwaitingInsert.isNotEmpty() || + movableContentAwaitingInsert.isNotEmpty() || concurrentCompositionsOutstanding > 0 || hasBroadcastFrameClockAwaitersLocked -> State.PendingWork else -> State.Idle @@ -556,8 +571,8 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( fun fillToInsert() { toInsert.clear() synchronized(stateLock) { - compositionValuesAwaitingInsert.fastForEach { toInsert += it } - compositionValuesAwaitingInsert.clear() + movableContentAwaitingInsert.fastForEach { toInsert += it } + movableContentAwaitingInsert.clear() } } @@ -715,7 +730,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( } } - discardUnusedValues() + discardUnusedMovableContentState() } } @@ -732,9 +747,9 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( compositionInvalidations.clear() snapshotInvalidations = MutableScatterSet() - compositionValuesAwaitingInsert.clear() - compositionValuesRemoved.clear() - compositionValueStatesAvailable.clear() + movableContentAwaitingInsert.clear() + movableContentRemoved.clear() + movableContentStatesAvailable.clear() errorState = RecomposerErrorState(recoverable = recoverable, cause = e) @@ -767,19 +782,54 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( } } + @OptIn(ExperimentalComposeRuntimeApi::class) private fun clearKnownCompositionsLocked() { + registrationObservers?.forEach { observer -> + knownCompositions.forEach { composition -> + observer.onCompositionUnregistered(this, composition) + } + } _knownCompositions.clear() _knownCompositionsCache = emptyList() } + @OptIn(ExperimentalComposeRuntimeApi::class) private fun removeKnownCompositionLocked(composition: ControlledComposition) { - _knownCompositions -= composition - _knownCompositionsCache = null + if (_knownCompositions.remove(composition)) { + _knownCompositionsCache = null + registrationObservers?.forEach { it.onCompositionUnregistered(this, composition) } + } } + @OptIn(ExperimentalComposeRuntimeApi::class) private fun addKnownCompositionLocked(composition: ControlledComposition) { _knownCompositions += composition _knownCompositionsCache = null + registrationObservers?.forEach { it.onCompositionRegistered(this, composition) } + } + + @ExperimentalComposeRuntimeApi + internal fun addCompositionRegistrationObserver( + observer: CompositionRegistrationObserver + ): CompositionObserverHandle { + synchronized(stateLock) { + val observers = + registrationObservers + ?: MutableObjectList().also { + registrationObservers = it + } + + observers += observer + _knownCompositions.fastForEach { composition -> + observer.onCompositionRegistered(this@Recomposer, composition) + } + } + + return object : CompositionObserverHandle { + override fun dispose() { + synchronized(stateLock) { registrationObservers?.remove(observer) } + } + } } private fun resetErrorState(): RecomposerErrorState? { @@ -1122,7 +1172,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( internal override fun composeInitialPaused( composition: ControlledComposition, - shouldPause: () -> Boolean, + shouldPause: ShouldPauseCallback, content: @Composable () -> Unit ): ScatterSet { return try { @@ -1137,7 +1187,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( internal override fun recomposePaused( composition: ControlledComposition, - shouldPause: () -> Boolean, + shouldPause: ShouldPauseCallback, invalidScopes: ScatterSet ): ScatterSet { return try { @@ -1170,13 +1220,13 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( private fun performInitialMovableContentInserts(composition: ControlledComposition) { synchronized(stateLock) { - if (!compositionValuesAwaitingInsert.fastAny { it.composition == composition }) return + if (!movableContentAwaitingInsert.fastAny { it.composition == composition }) return } val toInsert = mutableListOf() fun fillToInsert() { toInsert.clear() synchronized(stateLock) { - val iterator = compositionValuesAwaitingInsert.iterator() + val iterator = movableContentAwaitingInsert.iterator() while (iterator.hasNext()) { val value = iterator.next() if (value.composition == composition) { @@ -1220,6 +1270,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( else null } + @OptIn(ExperimentalComposeApi::class) private fun performInsertValues( references: List, modifiedValues: MutableScatterSet? @@ -1232,10 +1283,46 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( // during `performRecompose`. val pairs = synchronized(stateLock) { - refs.fastMap { reference -> - reference to - compositionValuesRemoved.removeLastMultiValue(reference.content) - } + refs + .fastMap { reference -> + reference to + movableContentRemoved.removeLast(reference.content).also { + if (it != null) { + movableContentNestedStatesAvailable.usedContainer(it) + } + } + } + .let { pairs -> + // Check for any nested states + if ( + ComposeRuntimeFlags.isMovingNestedMovableContentEnabled && + pairs.fastAny { + it.second == null && + it.first.content in + movableContentNestedStatesAvailable + } + ) { + // We have at least one nested state we could use, if a state + // is available for the container then schedule the state to be + // removed from the container when it is released. + pairs.map { pair -> + if (pair.second == null) { + val nestedContentReference = + movableContentNestedStatesAvailable.removeLast( + pair.first.content + ) + if (nestedContentReference == null) return@map pair + val content = nestedContentReference.content + val container = nestedContentReference.container + movableContentNestedExtractionsPending.add( + container, + content + ) + pair.first to content + } else pair + } + } else pairs + } } // Avoid mixing creating new content with moving content as the moved content @@ -1253,7 +1340,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( pairs.fastMapNotNull { item -> if (item.second == null) item.first else null } - synchronized(stateLock) { compositionValuesAwaitingInsert += toReturn } + synchronized(stateLock) { movableContentAwaitingInsert += toReturn } // Only insert the moving content this time pairs.fastFilterIndexed { _, item -> item.second != null } @@ -1273,19 +1360,21 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( return tasks.keys.toList() } - private fun discardUnusedValues() { + private fun discardUnusedMovableContentState() { val unusedValues = synchronized(stateLock) { - if (compositionValuesRemoved.isNotEmpty()) { - val references = compositionValuesRemoved.values.flatten() - compositionValuesRemoved.clear() + if (movableContentRemoved.isNotEmpty()) { + val references = movableContentRemoved.values() + movableContentRemoved.clear() + movableContentNestedStatesAvailable.clear() + movableContentNestedExtractionsPending.clear() val unusedValues = - references.fastMap { it to compositionValueStatesAvailable[it] } - compositionValueStatesAvailable.clear() + references.fastMap { it to movableContentStatesAvailable[it] } + movableContentStatesAvailable.clear() unusedValues - } else emptyList() + } else emptyObjectList() } - unusedValues.fastForEach { (reference, state) -> + unusedValues.forEach { (reference, state) -> if (state != null) { reference.composition.disposeUnusedMovableContent(state) } @@ -1453,7 +1542,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( internal override fun insertMovableContent(reference: MovableContentStateReference) { synchronized(stateLock) { - compositionValuesAwaitingInsert += reference + movableContentAwaitingInsert += reference deriveStateLocked() } ?.resume(Unit) @@ -1461,15 +1550,38 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( internal override fun deletedMovableContent(reference: MovableContentStateReference) { synchronized(stateLock) { - compositionValuesRemoved.addMultiValue(reference.content, reference) + movableContentRemoved.add(reference.content, reference) + if (reference.nestedReferences != null) { + val container = reference + fun recordNestedStatesOf(reference: MovableContentStateReference) { + reference.nestedReferences?.fastForEach { nestedReference -> + movableContentNestedStatesAvailable.add( + nestedReference.content, + NestedMovableContent(nestedReference, container) + ) + recordNestedStatesOf(nestedReference) + } + } + recordNestedStatesOf(reference) + } } } internal override fun movableContentStateReleased( reference: MovableContentStateReference, - data: MovableContentState + data: MovableContentState, + applier: Applier<*>, ) { - synchronized(stateLock) { compositionValueStatesAvailable[reference] = data } + synchronized(stateLock) { + movableContentStatesAvailable[reference] = data + val extractions = movableContentNestedExtractionsPending[reference] + if (extractions.isNotEmpty()) { + val states = data.extractNestedStates(applier, extractions) + states.forEach { reference, state -> + movableContentStatesAvailable[reference] = state + } + } + } } internal override fun reportRemovedComposition(composition: ControlledComposition) { @@ -1484,7 +1596,7 @@ class Recomposer(effectCoroutineContext: CoroutineContext) : CompositionContext( override fun movableContentStateResolve( reference: MovableContentStateReference ): MovableContentState? = - synchronized(stateLock) { compositionValueStatesAvailable.remove(reference) } + synchronized(stateLock) { movableContentStatesAvailable.remove(reference) } /** * hack: the companion object is thread local in Kotlin/Native to avoid freezing @@ -1637,9 +1749,35 @@ private class ProduceFrameSignal { } } -// Allow treating a mutable map of shape MutableMap> as a multi-value map -internal fun MutableMap>.addMultiValue(key: K, value: V) = - getOrPut(key) { mutableListOf() }.add(value) +@OptIn(InternalComposeApi::class) +private class NestedContentMap { + private val contentMap = MultiValueMap, NestedMovableContent>() + private val containerMap = MultiValueMap>() + + fun add(content: MovableContent, nestedContent: NestedMovableContent) { + contentMap.add(content, nestedContent) + containerMap.add(nestedContent.container, content) + } + + fun clear() { + contentMap.clear() + containerMap.clear() + } + + fun removeLast(key: MovableContent) = + contentMap.removeLast(key).also { if (contentMap.isEmpty()) containerMap.clear() } + + operator fun contains(key: MovableContent) = key in contentMap -internal fun MutableMap>.removeLastMultiValue(key: K): V? = - get(key)?.let { list -> list.removeFirstKt().also { if (list.isEmpty()) remove(key) } } + fun usedContainer(reference: MovableContentStateReference) { + containerMap.forEachValue(reference) { value -> + contentMap.removeValueIf(value) { it.container == reference } + } + } +} + +@InternalComposeApi +private class NestedMovableContent( + val content: MovableContentStateReference, + val container: MovableContentStateReference +) diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt index 5c622d62e8a2e..cd6d63e5b0ea7 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt @@ -16,15 +16,20 @@ package androidx.compose.runtime +import androidx.collection.MutableIntList import androidx.collection.MutableIntObjectMap import androidx.collection.MutableIntSet import androidx.collection.MutableObjectList +import androidx.collection.mutableIntListOf +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import androidx.compose.runtime.snapshots.fastAny import androidx.compose.runtime.snapshots.fastFilterIndexed import androidx.compose.runtime.snapshots.fastForEach import androidx.compose.runtime.snapshots.fastMap import androidx.compose.runtime.tooling.CompositionData import androidx.compose.runtime.tooling.CompositionGroup +import kotlin.jvm.JvmInline import kotlin.math.max import kotlin.math.min @@ -112,7 +117,7 @@ internal class SlotTable : CompositionData, Iterable { */ private var readers = 0 - private val lock = SynchronizedObject() + private val lock = makeSynchronizedObject() /** Tracks whether there is an active writer. */ internal var writer = false @@ -1600,7 +1605,8 @@ internal class SlotWriter( } /** Set the group's slot at [index] to [value]. Returns the previous value. */ - fun set(index: Int, value: Any?): Any? = set(currentGroup, index, value) + @Suppress("NOTHING_TO_INLINE") + inline fun set(index: Int, value: Any?): Any? = set(currentGroup, index, value) /** Convert a slot group index into a global slot index. */ fun slotIndexOfGroupSlotIndex(group: Int, index: Int): Int { @@ -3798,7 +3804,8 @@ private fun ArrayList.locationOf(index: Int, effectiveSize: Int) = * that ensures that adding or removing a value is O(log N) operation even if values are repeatedly * added and removed. */ -internal class PrioritySet(private val list: MutableList = mutableListOf()) { +@JvmInline +internal value class PrioritySet(private val list: MutableIntList = mutableIntListOf()) { // Add a value to the heap fun add(value: Int) { // Filter trivial duplicates diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotDoubleState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotDoubleState.kt index 81a6b6301e9c9..9506a5ab2e07f 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotDoubleState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotDoubleState.kt @@ -22,13 +22,17 @@ package androidx.compose.runtime import androidx.compose.runtime.internal.JvmDefaultWithCompatibility import androidx.compose.runtime.internal.equalsWithNanFix import androidx.compose.runtime.snapshots.AutoboxingStateValueProperty +import androidx.compose.runtime.snapshots.GlobalSnapshot import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotId import androidx.compose.runtime.snapshots.SnapshotMutableState import androidx.compose.runtime.snapshots.StateFactoryMarker import androidx.compose.runtime.snapshots.StateObjectImpl import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.currentSnapshot import androidx.compose.runtime.snapshots.overwritable import androidx.compose.runtime.snapshots.readable +import androidx.compose.runtime.snapshots.toSnapshotId import androidx.compose.runtime.snapshots.withCurrent import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -124,12 +128,12 @@ internal open class SnapshotMutableDoubleStateImpl(value: Double) : StateObjectImpl(), MutableDoubleState, SnapshotMutableState { private var next = - DoubleStateStateRecord(value).also { - if (Snapshot.isInSnapshot) { - it.next = - DoubleStateStateRecord(value).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + currentSnapshot().let { snapshot -> + DoubleStateStateRecord(snapshot.snapshotId, value).also { + if (snapshot !is GlobalSnapshot) { + it.next = + DoubleStateStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), value) + } } } @@ -175,11 +179,15 @@ internal open class SnapshotMutableDoubleStateImpl(value: Double) : override fun toString(): String = next.withCurrent { "MutableDoubleState(value=${it.value})@${hashCode()}" } - private class DoubleStateStateRecord(var value: Double) : StateRecord() { + private class DoubleStateStateRecord(snapshotId: SnapshotId, var value: Double) : + StateRecord(snapshotId) { override fun assign(value: StateRecord) { this.value = (value as DoubleStateStateRecord).value } - override fun create(): StateRecord = DoubleStateStateRecord(value) + override fun create(): StateRecord = create(snapshotId) + + override fun create(snapshotId: SnapshotId): StateRecord = + DoubleStateStateRecord(snapshotId, value) } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotFloatState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotFloatState.kt index afe2f150d46e5..8e94ea330decb 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotFloatState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotFloatState.kt @@ -22,13 +22,17 @@ package androidx.compose.runtime import androidx.compose.runtime.internal.JvmDefaultWithCompatibility import androidx.compose.runtime.internal.equalsWithNanFix import androidx.compose.runtime.snapshots.AutoboxingStateValueProperty +import androidx.compose.runtime.snapshots.GlobalSnapshot import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotId import androidx.compose.runtime.snapshots.SnapshotMutableState import androidx.compose.runtime.snapshots.StateFactoryMarker import androidx.compose.runtime.snapshots.StateObjectImpl import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.currentSnapshot import androidx.compose.runtime.snapshots.overwritable import androidx.compose.runtime.snapshots.readable +import androidx.compose.runtime.snapshots.toSnapshotId import androidx.compose.runtime.snapshots.withCurrent import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -122,12 +126,12 @@ internal open class SnapshotMutableFloatStateImpl(value: Float) : StateObjectImpl(), MutableFloatState, SnapshotMutableState { private var next = - FloatStateStateRecord(value).also { - if (Snapshot.isInSnapshot) { - it.next = - FloatStateStateRecord(value).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + currentSnapshot().let { snapshot -> + FloatStateStateRecord(snapshot.snapshotId, value).also { + if (snapshot !is GlobalSnapshot) { + it.next = + FloatStateStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), value) + } } } @@ -173,11 +177,15 @@ internal open class SnapshotMutableFloatStateImpl(value: Float) : override fun toString(): String = next.withCurrent { "MutableFloatState(value=${it.value})@${hashCode()}" } - private class FloatStateStateRecord(var value: Float) : StateRecord() { + private class FloatStateStateRecord(snapshotId: SnapshotId, var value: Float) : + StateRecord(snapshotId) { override fun assign(value: StateRecord) { this.value = (value as FloatStateStateRecord).value } - override fun create(): StateRecord = FloatStateStateRecord(value) + override fun create(): StateRecord = create(currentSnapshot().snapshotId) + + override fun create(snapshotId: SnapshotId): StateRecord = + FloatStateStateRecord(snapshotId, value) } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotIntState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotIntState.kt index 107df598561d5..dc998428f0189 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotIntState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotIntState.kt @@ -21,13 +21,17 @@ package androidx.compose.runtime import androidx.compose.runtime.internal.JvmDefaultWithCompatibility import androidx.compose.runtime.snapshots.AutoboxingStateValueProperty +import androidx.compose.runtime.snapshots.GlobalSnapshot import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotId import androidx.compose.runtime.snapshots.SnapshotMutableState import androidx.compose.runtime.snapshots.StateFactoryMarker import androidx.compose.runtime.snapshots.StateObjectImpl import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.currentSnapshot import androidx.compose.runtime.snapshots.overwritable import androidx.compose.runtime.snapshots.readable +import androidx.compose.runtime.snapshots.toSnapshotId import androidx.compose.runtime.snapshots.withCurrent import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -121,12 +125,12 @@ internal open class SnapshotMutableIntStateImpl(value: Int) : StateObjectImpl(), MutableIntState, SnapshotMutableState { private var next = - IntStateStateRecord(value).also { - if (Snapshot.isInSnapshot) { - it.next = - IntStateStateRecord(value).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + currentSnapshot().let { snapshot -> + IntStateStateRecord(snapshot.snapshotId, value).also { + if (snapshot !is GlobalSnapshot) { + it.next = + IntStateStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), value) + } } } @@ -176,11 +180,15 @@ internal open class SnapshotMutableIntStateImpl(value: Int) : val debuggerDisplayValue: Int @JvmName("getDebuggerDisplayValue") get() = next.withCurrent { it.value } - private class IntStateStateRecord(var value: Int) : StateRecord() { + private class IntStateStateRecord(snapshotId: SnapshotId, var value: Int) : + StateRecord(snapshotId) { override fun assign(value: StateRecord) { this.value = (value as IntStateStateRecord).value } - override fun create(): StateRecord = IntStateStateRecord(value) + override fun create(): StateRecord = create(currentSnapshot().snapshotId) + + override fun create(snapshotId: SnapshotId): StateRecord = + IntStateStateRecord(snapshotId, value) } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotLongState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotLongState.kt index 22a244fc2b1ba..407b127e738b9 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotLongState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotLongState.kt @@ -21,13 +21,17 @@ package androidx.compose.runtime import androidx.compose.runtime.internal.JvmDefaultWithCompatibility import androidx.compose.runtime.snapshots.AutoboxingStateValueProperty +import androidx.compose.runtime.snapshots.GlobalSnapshot import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotId import androidx.compose.runtime.snapshots.SnapshotMutableState import androidx.compose.runtime.snapshots.StateFactoryMarker import androidx.compose.runtime.snapshots.StateObjectImpl import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.currentSnapshot import androidx.compose.runtime.snapshots.overwritable import androidx.compose.runtime.snapshots.readable +import androidx.compose.runtime.snapshots.toSnapshotId import androidx.compose.runtime.snapshots.withCurrent import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -117,12 +121,12 @@ internal open class SnapshotMutableLongStateImpl(value: Long) : StateObjectImpl(), MutableLongState, SnapshotMutableState { private var next = - LongStateStateRecord(value).also { - if (Snapshot.isInSnapshot) { - it.next = - LongStateStateRecord(value).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + currentSnapshot().let { snapshot -> + LongStateStateRecord(snapshot.snapshotId, value).also { + if (snapshot !is GlobalSnapshot) { + it.next = + LongStateStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), value) + } } } @@ -168,11 +172,15 @@ internal open class SnapshotMutableLongStateImpl(value: Long) : override fun toString(): String = next.withCurrent { "MutableLongState(value=${it.value})@${hashCode()}" } - private class LongStateStateRecord(var value: Long) : StateRecord() { + private class LongStateStateRecord(snapshotId: SnapshotId, var value: Long) : + StateRecord(snapshotId) { override fun assign(value: StateRecord) { this.value = (value as LongStateStateRecord).value } - override fun create(): StateRecord = LongStateStateRecord(value) + override fun create(): StateRecord = create(currentSnapshot().snapshotId) + + override fun create(snapshotId: SnapshotId): StateRecord = + LongStateStateRecord(snapshotId, value) } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt index 6aa78652e79d1..7dc8c8b182680 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt @@ -19,7 +19,9 @@ package androidx.compose.runtime +import androidx.compose.runtime.snapshots.GlobalSnapshot import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotId import androidx.compose.runtime.snapshots.SnapshotMutableState import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateMap @@ -27,8 +29,10 @@ import androidx.compose.runtime.snapshots.SnapshotStateSet import androidx.compose.runtime.snapshots.StateFactoryMarker import androidx.compose.runtime.snapshots.StateObjectImpl import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.currentSnapshot import androidx.compose.runtime.snapshots.overwritable import androidx.compose.runtime.snapshots.readable +import androidx.compose.runtime.snapshots.toSnapshotId import androidx.compose.runtime.snapshots.withCurrent import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -140,12 +144,11 @@ internal open class SnapshotMutableStateImpl( } private var next: StateStateRecord = - StateStateRecord(value).also { - if (Snapshot.isInSnapshot) { - it.next = - StateStateRecord(value).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + currentSnapshot().let { snapshot -> + StateStateRecord(snapshot.snapshotId, value).also { + if (snapshot !is GlobalSnapshot) { + it.next = StateStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), value) + } } } @@ -171,7 +174,7 @@ internal open class SnapshotMutableStateImpl( val merged = policy.merge(previousRecord.value, currentRecord.value, appliedRecord.value) if (merged != null) { - appliedRecord.create().also { (it as StateStateRecord).value = merged } + appliedRecord.create(appliedRecord.snapshotId).also { it.value = merged } } else { null } @@ -181,13 +184,17 @@ internal open class SnapshotMutableStateImpl( override fun toString(): String = next.withCurrent { "MutableState(value=${it.value})@${hashCode()}" } - private class StateStateRecord(myValue: T) : StateRecord() { + private class StateStateRecord(snapshotId: SnapshotId, myValue: T) : + StateRecord(snapshotId) { override fun assign(value: StateRecord) { @Suppress("UNCHECKED_CAST") this.value = (value as StateStateRecord).value } - override fun create(): StateRecord = StateStateRecord(value) + override fun create() = StateStateRecord(currentSnapshot().snapshotId, value) + + override fun create(snapshotId: SnapshotId) = + StateStateRecord(currentSnapshot().snapshotId, value) var value: T = myValue } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/ChangeList.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/ChangeList.kt index e2eaa765afc6a..f7e0b62466d65 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/ChangeList.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/ChangeList.kt @@ -112,16 +112,14 @@ internal class ChangeList : OperationsDebugStringFormattable() { fun pushUpdateAnchoredValue(value: Any?, anchor: Anchor, groupSlotIndex: Int) { operations.push(UpdateAnchoredValue) { - setObject(UpdateAnchoredValue.Value, value) - setObject(UpdateAnchoredValue.Anchor, anchor) + setObjects(UpdateAnchoredValue.Value, value, UpdateAnchoredValue.Anchor, anchor) setInt(UpdateAnchoredValue.GroupSlotIndex, groupSlotIndex) } } fun pushAppendValue(anchor: Anchor, value: Any?) { operations.push(AppendValue) { - setObject(AppendValue.Anchor, anchor) - setObject(AppendValue.Value, value) + setObjects(AppendValue.Anchor, anchor, AppendValue.Value, value) } } @@ -163,16 +161,20 @@ internal class ChangeList : OperationsDebugStringFormattable() { fun pushInsertSlots(anchor: Anchor, from: SlotTable) { operations.push(InsertSlots) { - setObject(InsertSlots.Anchor, anchor) - setObject(InsertSlots.FromSlotTable, from) + setObjects(InsertSlots.Anchor, anchor, InsertSlots.FromSlotTable, from) } } fun pushInsertSlots(anchor: Anchor, from: SlotTable, fixups: FixupList) { operations.push(InsertSlotsWithFixups) { - setObject(InsertSlotsWithFixups.Anchor, anchor) - setObject(InsertSlotsWithFixups.FromSlotTable, from) - setObject(InsertSlotsWithFixups.Fixups, fixups) + setObjects( + InsertSlotsWithFixups.Anchor, + anchor, + InsertSlotsWithFixups.FromSlotTable, + from, + InsertSlotsWithFixups.Fixups, + fixups + ) } } @@ -182,8 +184,12 @@ internal class ChangeList : OperationsDebugStringFormattable() { fun pushEndCompositionScope(action: (Composition) -> Unit, composition: Composition) { operations.push(EndCompositionScope) { - setObject(EndCompositionScope.Action, action) - setObject(EndCompositionScope.Composition, composition) + setObjects( + EndCompositionScope.Action, + action, + EndCompositionScope.Composition, + composition + ) } } @@ -195,23 +201,20 @@ internal class ChangeList : OperationsDebugStringFormattable() { fun pushUpdateNode(value: V, block: T.(V) -> Unit) { operations.push(UpdateNode) { - setObject(UpdateNode.Value, value) - @Suppress("UNCHECKED_CAST") setObject(UpdateNode.Block, block as (Any?.(Any?) -> Unit)) + @Suppress("UNCHECKED_CAST") + setObjects(UpdateNode.Value, value, UpdateNode.Block, block as (Any?.(Any?) -> Unit)) } } fun pushRemoveNode(removeFrom: Int, moveCount: Int) { operations.push(RemoveNode) { - setInt(RemoveNode.RemoveIndex, removeFrom) - setInt(RemoveNode.Count, moveCount) + setInts(RemoveNode.RemoveIndex, removeFrom, RemoveNode.Count, moveCount) } } fun pushMoveNode(to: Int, from: Int, count: Int) { operations.push(MoveNode) { - setInt(MoveNode.To, to) - setInt(MoveNode.From, from) - setInt(MoveNode.Count, count) + setInts(MoveNode.To, to, MoveNode.From, from, MoveNode.Count, count) } } @@ -235,16 +238,24 @@ internal class ChangeList : OperationsDebugStringFormattable() { fun pushDetermineMovableContentNodeIndex(effectiveNodeIndexOut: IntRef, anchor: Anchor) { operations.push(DetermineMovableContentNodeIndex) { - setObject(DetermineMovableContentNodeIndex.EffectiveNodeIndexOut, effectiveNodeIndexOut) - setObject(DetermineMovableContentNodeIndex.Anchor, anchor) + setObjects( + DetermineMovableContentNodeIndex.EffectiveNodeIndexOut, + effectiveNodeIndexOut, + DetermineMovableContentNodeIndex.Anchor, + anchor + ) } } fun pushCopyNodesToNewAnchorLocation(nodes: List, effectiveNodeIndex: IntRef) { if (nodes.isNotEmpty()) { operations.push(CopyNodesToNewAnchorLocation) { - setObject(CopyNodesToNewAnchorLocation.Nodes, nodes) - setObject(CopyNodesToNewAnchorLocation.EffectiveNodeIndex, effectiveNodeIndex) + setObjects( + CopyNodesToNewAnchorLocation.Nodes, + nodes, + CopyNodesToNewAnchorLocation.EffectiveNodeIndex, + effectiveNodeIndex + ) } } } @@ -257,10 +268,16 @@ internal class ChangeList : OperationsDebugStringFormattable() { to: MovableContentStateReference, ) { operations.push(CopySlotTableToAnchorLocation) { - setObject(CopySlotTableToAnchorLocation.ResolvedState, resolvedState) - setObject(CopySlotTableToAnchorLocation.ParentCompositionContext, parentContext) - setObject(CopySlotTableToAnchorLocation.To, to) - setObject(CopySlotTableToAnchorLocation.From, from) + setObjects( + CopySlotTableToAnchorLocation.ResolvedState, + resolvedState, + CopySlotTableToAnchorLocation.ParentCompositionContext, + parentContext, + CopySlotTableToAnchorLocation.To, + to, + CopySlotTableToAnchorLocation.From, + from + ) } } @@ -271,9 +288,14 @@ internal class ChangeList : OperationsDebugStringFormattable() { reference: MovableContentStateReference ) { operations.push(ReleaseMovableGroupAtCurrent) { - setObject(ReleaseMovableGroupAtCurrent.Composition, composition) - setObject(ReleaseMovableGroupAtCurrent.ParentCompositionContext, parentContext) - setObject(ReleaseMovableGroupAtCurrent.Reference, reference) + setObjects( + ReleaseMovableGroupAtCurrent.Composition, + composition, + ReleaseMovableGroupAtCurrent.ParentCompositionContext, + parentContext, + ReleaseMovableGroupAtCurrent.Reference, + reference + ) } } @@ -284,8 +306,12 @@ internal class ChangeList : OperationsDebugStringFormattable() { fun pushExecuteOperationsIn(changeList: ChangeList, effectiveNodeIndex: IntRef? = null) { if (changeList.isNotEmpty()) { operations.push(ApplyChangeList) { - setObject(ApplyChangeList.Changes, changeList) - setObject(ApplyChangeList.EffectiveNodeIndex, effectiveNodeIndex) + setObjects( + ApplyChangeList.Changes, + changeList, + ApplyChangeList.EffectiveNodeIndex, + effectiveNodeIndex + ) } } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operation.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operation.kt index 15aa23474ad15..f3989d46ece02 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operation.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operation.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.Composition import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.ControlledComposition import androidx.compose.runtime.InternalComposeApi -import androidx.compose.runtime.InvalidationResult import androidx.compose.runtime.MovableContentState import androidx.compose.runtime.MovableContentStateReference import androidx.compose.runtime.OffsetApplier @@ -36,17 +35,18 @@ import androidx.compose.runtime.SlotWriter import androidx.compose.runtime.TestOnly import androidx.compose.runtime.composeRuntimeError import androidx.compose.runtime.deactivateCurrentGroup +import androidx.compose.runtime.extractMovableContentAtCurrent import androidx.compose.runtime.internal.IntRef import androidx.compose.runtime.internal.identityHashCode -import androidx.compose.runtime.movableContentKey import androidx.compose.runtime.removeCurrentGroup import androidx.compose.runtime.runtimeCheck import androidx.compose.runtime.snapshots.fastForEachIndexed import androidx.compose.runtime.withAfterAnchorInfo import kotlin.jvm.JvmInline -internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { +internal typealias IntParameter = Int +internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { val name: String get() = this::class.simpleName.orEmpty() @@ -56,21 +56,19 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { rememberManager: RememberManager ) - open fun intParamName(parameter: IntParameter): String = "IntParameter(${parameter.offset})" + open fun intParamName(parameter: IntParameter): String = "IntParameter(${parameter})" open fun objectParamName(parameter: ObjectParameter<*>): String = "ObjectParameter(${parameter.offset})" override fun toString() = name - @JvmInline value class IntParameter(val offset: Int) - @JvmInline value class ObjectParameter(val offset: Int) // region traversal operations object Ups : Operation(ints = 1) { inline val Count - get() = IntParameter(0) + get() = 0 override fun intParamName(parameter: IntParameter) = when (parameter) { @@ -112,7 +110,7 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { object AdvanceSlotsBy : Operation(ints = 1) { inline val Distance - get() = IntParameter(0) + get() = 0 override fun intParamName(parameter: IntParameter) = when (parameter) { @@ -260,7 +258,7 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { object TrimParentValues : Operation(ints = 1) { inline val Count - get() = IntParameter(0) + get() = 0 override fun intParamName(parameter: IntParameter): String = when (parameter) { @@ -302,7 +300,7 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { get() = ObjectParameter(0) inline val GroupSlotIndex - get() = IntParameter(0) + get() = 0 override fun intParamName(parameter: IntParameter) = when (parameter) { @@ -348,18 +346,18 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { get() = ObjectParameter(1) inline val GroupSlotIndex - get() = IntParameter(0) + get() = 0 override fun intParamName(parameter: IntParameter) = when (parameter) { - UpdateAnchoredValue.GroupSlotIndex -> "groupSlotIndex" + GroupSlotIndex -> "groupSlotIndex" else -> super.intParamName(parameter) } override fun objectParamName(parameter: ObjectParameter<*>) = when (parameter) { - UpdateAnchoredValue.Value -> "value" - UpdateAnchoredValue.Anchor -> "anchor" + Value -> "value" + Anchor -> "anchor" else -> super.objectParamName(parameter) } @@ -368,9 +366,9 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { slots: SlotWriter, rememberManager: RememberManager ) { - val value = getObject(UpdateAnchoredValue.Value) - val anchor = getObject(UpdateAnchoredValue.Anchor) - val groupSlotIndex = getInt(UpdateAnchoredValue.GroupSlotIndex) + val value = getObject(Value) + val anchor = getObject(Anchor) + val groupSlotIndex = getInt(GroupSlotIndex) if (value is RememberObserverHolder) { rememberManager.remembering(value.wrapped) } @@ -457,7 +455,7 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { object MoveCurrentGroup : Operation(ints = 1) { inline val Offset - get() = IntParameter(0) + get() = 0 override fun intParamName(parameter: IntParameter) = when (parameter) { @@ -557,10 +555,10 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { object RemoveNode : Operation(ints = 2) { inline val RemoveIndex - get() = IntParameter(0) + get() = 0 inline val Count - get() = IntParameter(1) + get() = 1 override fun intParamName(parameter: IntParameter) = when (parameter) { @@ -580,13 +578,13 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { object MoveNode : Operation(ints = 3) { inline val From - get() = IntParameter(0) + get() = 0 inline val To - get() = IntParameter(1) + get() = 1 inline val Count - get() = IntParameter(2) + get() = 2 override fun intParamName(parameter: IntParameter) = when (parameter) { @@ -682,7 +680,7 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { get() = ObjectParameter<() -> Any?>(0) inline val InsertIndex - get() = IntParameter(0) + get() = 0 inline val GroupAnchor get() = ObjectParameter(1) @@ -718,7 +716,7 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { object PostInsertNodeFixup : Operation(ints = 1, objects = 1) { inline val InsertIndex - get() = IntParameter(0) + get() = 0 inline val GroupAnchor get() = ObjectParameter(0) @@ -928,12 +926,17 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { slots: SlotWriter, rememberManager: RememberManager ) { - releaseMovableGroupAtCurrent( - composition = getObject(Composition), - parentContext = getObject(ParentCompositionContext), - reference = getObject(Reference), - slots = slots - ) + val composition = getObject(Composition) + val reference = getObject(Reference) + val parentContext = getObject(ParentCompositionContext) + val state = + extractMovableContentAtCurrent( + composition = composition, + reference = reference, + slots = slots, + applier = null, + ) + parentContext.movableContentStateReleased(reference, state, applier) } } @@ -986,7 +989,7 @@ internal sealed class Operation(val ints: Int = 0, val objects: Int = 0) { objects: Int = 0, val block: (Applier<*>, SlotWriter, RememberManager) -> Unit = { _, _, _ -> } ) : Operation(ints, objects) { - val intParams = List(ints) { index -> IntParameter(index) } + @Suppress("PrimitiveInCollection") val intParams = List(ints) { it } val objParams = List(objects) { index -> ObjectParameter(index) } override fun OperationArgContainer.execute( @@ -1052,99 +1055,3 @@ private fun positionToInsert(slots: SlotWriter, anchor: Anchor, applier: Applier runtimeCheck(slots.currentGroup == destination) return nodeIndex } - -/** - * Release the movable group stored in [slots] to the recomposer to be used to insert in another - * location if needed. - */ -@OptIn(InternalComposeApi::class) -private fun releaseMovableGroupAtCurrent( - composition: ControlledComposition, - parentContext: CompositionContext, - reference: MovableContentStateReference, - slots: SlotWriter -) { - val slotTable = SlotTable() - if (slots.collectingSourceInformation) { - slotTable.collectSourceInformation() - } - if (slots.collectingCalledInformation) { - slotTable.collectCalledByInformation() - } - - // Write a table that as if it was written by a calling - // invokeMovableContentLambda because this might be removed from the - // composition before the new composition can be composed to receive it. When - // the new composition receives the state it must recompose over the state by - // calling invokeMovableContentLambda. - val anchors = - slotTable.write { writer -> - writer.beginInsert() - - // This is the prefix created by invokeMovableContentLambda - writer.startGroup(movableContentKey, reference.content) - writer.markGroup() - writer.update(reference.parameter) - - // Move the content into current location - val anchors = slots.moveTo(reference.anchor, 1, writer) - - // skip the group that was just inserted. - writer.skipGroup() - - // End the group that represents the call to invokeMovableContentLambda - writer.endGroup() - - writer.endInsert() - - anchors - } - - val state = MovableContentState(slotTable) - if (RecomposeScopeImpl.hasAnchoredRecomposeScopes(slotTable, anchors)) { - // If any recompose scopes are invalidated while the movable content is outside - // a composition, ensure the reference is updated to contain the invalidation. - val movableContentRecomposeScopeOwner = - object : RecomposeScopeOwner { - override fun invalidate( - scope: RecomposeScopeImpl, - instance: Any? - ): InvalidationResult { - // Try sending this to the original owner first. - val result = - (composition as? RecomposeScopeOwner)?.invalidate(scope, instance) - ?: InvalidationResult.IGNORED - - // If the original owner ignores this then we need to record it in the - // reference - if (result == InvalidationResult.IGNORED) { - reference.invalidations += scope to instance - return InvalidationResult.SCHEDULED - } - return result - } - - // The only reason [recomposeScopeReleased] is called is when the recompose scope is - // removed from the table. First, this never happens for content that is moving, and - // 2) even if it did the only reason we tell the composer is to clear tracking - // tables that contain this information which is not relevant here. - override fun recomposeScopeReleased(scope: RecomposeScopeImpl) { - // Nothing to do - } - - // [recordReadOf] this is also something that would happen only during active - // recomposition which doesn't happened to a slot table that is moving. - override fun recordReadOf(value: Any) { - // Nothing to do - } - } - slotTable.write { writer -> - RecomposeScopeImpl.adoptAnchoredScopes( - slots = writer, - anchors = anchors, - newOwner = movableContentRecomposeScopeOwner - ) - } - } - parentContext.movableContentStateReleased(reference, state) -} diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/OperationArgContainer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/OperationArgContainer.kt index e8ac540499d33..ce804585faf73 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/OperationArgContainer.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/OperationArgContainer.kt @@ -19,7 +19,7 @@ package androidx.compose.runtime.changelist /** A container of parameters assigned to the arguments of an [Operation]. */ internal interface OperationArgContainer { /** Returns the assigned value of [parameter] for the current operation. */ - fun getInt(parameter: Operation.IntParameter): Int + fun getInt(parameter: Int): Int /** Returns the assigned value of [parameter] for the current operation. */ fun getObject(parameter: Operation.ObjectParameter): T diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt index 5ccd71e5d35fd..f250bc8988f7a 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt @@ -14,21 +14,27 @@ * limitations under the License. */ +@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") + package androidx.compose.runtime.changelist import androidx.compose.runtime.Applier +import androidx.compose.runtime.EnableDebugRuntimeChecks import androidx.compose.runtime.InternalComposeApi import androidx.compose.runtime.RememberManager import androidx.compose.runtime.SlotWriter -import androidx.compose.runtime.changelist.Operation.IntParameter import androidx.compose.runtime.changelist.Operation.ObjectParameter -import androidx.compose.runtime.checkPrecondition +import androidx.compose.runtime.debugRuntimeCheck import androidx.compose.runtime.requirePrecondition import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind.EXACTLY_ONCE import kotlin.contracts.contract +import kotlin.jvm.JvmField import kotlin.jvm.JvmInline +private const val OperationsMaxResizeAmount = 1024 +internal const val OperationsInitialCapacity = 16 + /** * `Operations` is a data structure used to store a sequence of [Operations][Operation] and their * respective arguments. Although the Stack is written to as a last-in-first-out structure, it is @@ -41,15 +47,24 @@ import kotlin.jvm.JvmInline * `Operations` is not a thread safe data structure. */ internal class Operations : OperationsDebugStringFormattable() { - - private var opCodes = arrayOfNulls(InitialCapacity) - private var opCodesSize = 0 - - private var intArgs = IntArray(InitialCapacity) - private var intArgsSize = 0 - - private var objectArgs = arrayOfNulls(InitialCapacity) - private var objectArgsSize = 0 + // To create an array of non-nullable references, Kotlin would normally force us to pass an + // initializer lambda to the array constructor, which could be expensive for larger arrays. + // Using an array of Operation? allows us to bypass the initialization of every entry, but it + // means that accessing an entry known to be non-null requires a null-check (via !! for + // instance), which produces unwanted code bloat in hot paths. The cast used here allows us to + // allocate the array as an array of Operation? but to use it as an array of Operation. + // When we want to remove an item from the array, we can cast it back to an array of Operation? + // to set the corresponding entry to null (see pop() for instance). + @Suppress("UNCHECKED_CAST") + @JvmField + internal var opCodes = arrayOfNulls(OperationsInitialCapacity) as Array + @JvmField internal var opCodesSize = 0 + + @JvmField internal var intArgs = IntArray(OperationsInitialCapacity) + @JvmField internal var intArgsSize = 0 + + @JvmField internal var objectArgs = arrayOfNulls(OperationsInitialCapacity) + @JvmField internal var objectArgsSize = 0 /* The two masks below are used to track which arguments have been assigned for the most @@ -96,13 +111,13 @@ internal class Operations : OperationsDebugStringFormattable() { */ @InternalComposeApi fun pushOp(operation: Operation) { - pushedIntMask = 0b0 - pushedObjectMask = 0b0 + if (EnableDebugRuntimeChecks) { + pushedIntMask = 0b0 + pushedObjectMask = 0b0 + } - // Resize arrays if needed if (opCodesSize == opCodes.size) { - val resizeAmount = opCodesSize.coerceAtMost(MaxResizeAmount) - opCodes = opCodes.copyOf(opCodesSize + resizeAmount) + resizeOpCodes() } ensureIntArgsSizeAtLeast(intArgsSize + operation.ints) ensureObjectArgsSizeAtLeast(objectArgsSize + operation.objects) @@ -114,24 +129,43 @@ internal class Operations : OperationsDebugStringFormattable() { } private fun determineNewSize(currentSize: Int, requiredSize: Int): Int { - val resizeAmount = currentSize.coerceAtMost(MaxResizeAmount) + val resizeAmount = currentSize.coerceAtMost(OperationsMaxResizeAmount) return (currentSize + resizeAmount).coerceAtLeast(requiredSize) } - private fun ensureIntArgsSizeAtLeast(requiredSize: Int) { + private fun resizeOpCodes() { + val resizeAmount = opCodesSize.coerceAtMost(OperationsMaxResizeAmount) + @Suppress("UNCHECKED_CAST") + val newOpCodes = arrayOfNulls(opCodesSize + resizeAmount) as Array + opCodes = opCodes.copyInto(newOpCodes, 0, 0, opCodesSize) + } + + private inline fun ensureIntArgsSizeAtLeast(requiredSize: Int) { val currentSize = intArgs.size if (requiredSize > currentSize) { - intArgs = intArgs.copyOf(determineNewSize(currentSize, requiredSize)) + resizeIntArgs(currentSize, requiredSize) } } - private fun ensureObjectArgsSizeAtLeast(requiredSize: Int) { + private fun resizeIntArgs(currentSize: Int, requiredSize: Int) { + val newIntArgs = IntArray(determineNewSize(currentSize, requiredSize)) + intArgs.copyInto(newIntArgs, 0, 0, currentSize) + intArgs = newIntArgs + } + + private inline fun ensureObjectArgsSizeAtLeast(requiredSize: Int) { val currentSize = objectArgs.size if (requiredSize > currentSize) { - objectArgs = objectArgs.copyOf(determineNewSize(currentSize, requiredSize)) + resizeObjectArgs(currentSize, requiredSize) } } + private fun resizeObjectArgs(currentSize: Int, requiredSize: Int) { + val newObjectArgs = arrayOfNulls(determineNewSize(currentSize, requiredSize)) + objectArgs.copyInto(newObjectArgs, 0, 0, currentSize) + objectArgs = newObjectArgs + } + /** * Adds an [operation] to the stack with no arguments. * @@ -140,13 +174,18 @@ internal class Operations : OperationsDebugStringFormattable() { * any arguments. */ fun push(operation: Operation) { - requirePrecondition(operation.ints == 0 && operation.objects == 0) { - "Cannot push $operation without arguments because it expects " + - "${operation.ints} ints and ${operation.objects} objects." + if (EnableDebugRuntimeChecks) { + requirePrecondition((operation.ints and operation.objects) == 0) { + exceptionMessageForOperationPushNoScope(operation) + } } @OptIn(InternalComposeApi::class) pushOp(operation) } + private fun exceptionMessageForOperationPushNoScope(operation: Operation) = + "Cannot push $operation without arguments because it expects " + + "${operation.ints} ints and ${operation.objects} objects." + /** * Adds an [operation] to the stack with arguments. To set arguments on the operation, call * [WriteScope.setObject] and [WriteScope.setInt] inside of the [args] lambda. @@ -164,37 +203,44 @@ internal class Operations : OperationsDebugStringFormattable() { @OptIn(InternalComposeApi::class) pushOp(operation) WriteScope(this).args() - // Verify all arguments were written to. - checkPrecondition( + ensureAllArgumentsPushedFor(operation) + } + + fun ensureAllArgumentsPushedFor(operation: Operation) { + debugRuntimeCheck( pushedIntMask == createExpectedArgMask(operation.ints) && pushedObjectMask == createExpectedArgMask(operation.objects) ) { - var missingIntCount = 0 - val missingInts = buildString { - repeat(operation.ints) { arg -> - if ((0b1 shl arg) and pushedIntMask != 0b0) { - if (missingIntCount > 0) append(", ") - append(operation.intParamName(IntParameter(arg))) - missingIntCount++ - } + exceptionMessageForOperationPushWithScope(operation) + } + } + + private fun exceptionMessageForOperationPushWithScope(operation: Operation): String { + var missingIntCount = 0 + val missingInts = buildString { + repeat(operation.ints) { arg -> + if ((0b1 shl arg) and pushedIntMask == 0b0) { + if (missingIntCount > 0) append(", ") + append(operation.intParamName(arg)) + missingIntCount++ } } + } - var missingObjectCount = 0 - val missingObjects = buildString { - repeat(operation.objects) { arg -> - if ((0b1 shl arg) and pushedObjectMask != 0b0) { - if (missingIntCount > 0) append(", ") - append(operation.objectParamName(ObjectParameter(arg))) - missingObjectCount++ - } + var missingObjectCount = 0 + val missingObjects = buildString { + repeat(operation.objects) { arg -> + if ((0b1 shl arg) and pushedObjectMask == 0b0) { + if (missingIntCount > 0) append(", ") + append(operation.objectParamName(ObjectParameter(arg))) + missingObjectCount++ } } - - "Error while pushing $operation. Not all arguments were provided. " + - "Missing $missingIntCount int arguments ($missingInts) " + - "and $missingObjectCount object arguments ($missingObjects)." } + + return "Error while pushing $operation. Not all arguments were provided. " + + "Missing $missingIntCount int arguments ($missingInts) " + + "and $missingObjectCount object arguments ($missingObjects)." } /** @@ -202,9 +248,10 @@ internal class Operations : OperationsDebugStringFormattable() { * bits are 0's. This corresponds to what [pushedIntMask] and [pushedObjectMask] will equal if * all [paramCount] arguments are set for the most recently pushed operation. */ - private fun createExpectedArgMask(paramCount: Int): Int { + private inline fun createExpectedArgMask(paramCount: Int): Int { // Calling ushr(32) no-ops instead of returning 0, so add a special case if paramCount is 0 - return if (paramCount == 0) 0 else 0b0.inv().ushr(Int.SIZE_BITS - paramCount) + // Keep the if/else in the parenthesis so we generate a single csetm on aarch64 + return (if (paramCount == 0) 0 else 0b0.inv()) ushr (Int.SIZE_BITS - paramCount) } /** @@ -212,15 +259,19 @@ internal class Operations : OperationsDebugStringFormattable() { * references. */ fun pop() { - if (isEmpty()) { - throw NoSuchElementException("Cannot pop(), because the stack is empty.") - } - val op = opCodes[--opCodesSize]!! - opCodes[opCodesSize] = null + // We could check for isEmpty(), instead we'll just let the array access throw an index out + // of bounds exception + val opCodes = opCodes + val op = opCodes[--opCodesSize] + // See comment where opCodes is defined + @Suppress("UNCHECKED_CAST") + (opCodes as Array)[opCodesSize] = null repeat(op.objects) { objectArgs[--objectArgsSize] = null } - repeat(op.ints) { intArgs[--intArgsSize] = 0 } + // We can just skip this work and leave the content of the array as is + // repeat(op.ints) { intArgs[--intArgsSize] = 0 } + intArgsSize -= op.ints } /** @@ -229,31 +280,32 @@ internal class Operations : OperationsDebugStringFormattable() { */ @OptIn(InternalComposeApi::class) fun popInto(other: Operations) { - if (isEmpty()) { - throw NoSuchElementException("Cannot pop(), because the stack is empty.") - } - val op = opCodes[--opCodesSize]!! - opCodes[opCodesSize] = null + // We could check for isEmpty(), instead we'll just let the array access throw an index out + // of bounds exception + val opCodes = opCodes + val op = opCodes[--opCodesSize] + // See comment where opCodes is defined + @Suppress("UNCHECKED_CAST") + (opCodes as Array)[opCodesSize] = null other.pushOp(op) - var thisObjIdx = objectArgsSize - var otherObjIdx = other.objectArgsSize - repeat(op.objects) { - otherObjIdx-- - thisObjIdx-- - other.objectArgs[otherObjIdx] = objectArgs[thisObjIdx] - objectArgs[thisObjIdx] = null - } - - var thisIntIdx = intArgsSize - var otherIntIdx = other.intArgsSize - repeat(op.ints) { - otherIntIdx-- - thisIntIdx-- - other.intArgs[otherIntIdx] = intArgs[thisIntIdx] - intArgs[thisIntIdx] = 0 - } + // Move the objects then null out our contents + objectArgs.copyInto( + destination = other.objectArgs, + destinationOffset = other.objectArgsSize - op.objects, + startIndex = objectArgsSize - op.objects, + endIndex = objectArgsSize + ) + objectArgs.fill(null, objectArgsSize - op.objects, objectArgsSize) + + // Move the ints too, but no need to clear our current values + intArgs.copyInto( + destination = other.intArgs, + destinationOffset = other.intArgsSize - op.ints, + startIndex = intArgsSize - op.ints, + endIndex = intArgsSize + ) objectArgsSize -= op.objects intArgsSize -= op.ints @@ -300,12 +352,12 @@ internal class Operations : OperationsDebugStringFormattable() { private fun String.indent() = "$this " - private fun peekOperation() = opCodes[opCodesSize - 1]!! + private inline fun peekOperation() = opCodes[opCodesSize - 1] - private fun topIntIndexOf(parameter: IntParameter) = - intArgsSize - peekOperation().ints + parameter.offset + private inline fun topIntIndexOf(parameter: IntParameter) = + intArgsSize - peekOperation().ints + parameter - private fun topObjectIndexOf(parameter: ObjectParameter<*>) = + private inline fun topObjectIndexOf(parameter: ObjectParameter<*>) = objectArgsSize - peekOperation().objects + parameter.offset @JvmInline @@ -313,25 +365,157 @@ internal class Operations : OperationsDebugStringFormattable() { val operation: Operation get() = stack.peekOperation() - fun setInt(parameter: IntParameter, value: Int) = + inline fun setInt(parameter: IntParameter, value: Int) = with(stack) { - val mask = 0b1 shl parameter.offset - checkPrecondition(pushedIntMask and mask == 0) { - "Already pushed argument ${operation.intParamName(parameter)}" + if (EnableDebugRuntimeChecks) { + val mask = 0b1 shl parameter + debugRuntimeCheck(pushedIntMask and mask == 0) { + "Already pushed argument ${operation.intParamName(parameter)}" + } + pushedIntMask = pushedIntMask or mask } - pushedIntMask = pushedIntMask or mask intArgs[topIntIndexOf(parameter)] = value } + inline fun setInts( + parameter1: IntParameter, + value1: Int, + parameter2: IntParameter, + value2: Int + ) = + with(stack) { + if (EnableDebugRuntimeChecks) { + val mask = (0b1 shl parameter1) or (0b1 shl parameter2) + debugRuntimeCheck(pushedIntMask and mask == 0) { + "Already pushed argument(s) ${operation.intParamName(parameter1)}" + + ", ${operation.intParamName(parameter2)}" + } + pushedIntMask = pushedIntMask or mask + } + val base = intArgsSize - peekOperation().ints + val intArgs = intArgs + intArgs[base + parameter1] = value1 + intArgs[base + parameter2] = value2 + } + + inline fun setInts( + parameter1: IntParameter, + value1: Int, + parameter2: IntParameter, + value2: Int, + parameter3: IntParameter, + value3: Int + ) = + with(stack) { + if (EnableDebugRuntimeChecks) { + val mask = (0b1 shl parameter1) or (0b1 shl parameter2) or (0b1 shl parameter3) + debugRuntimeCheck(pushedIntMask and mask == 0) { + "Already pushed argument(s) ${operation.intParamName(parameter1)}" + + ", ${operation.intParamName(parameter2)}" + + ", ${operation.intParamName(parameter3)}" + } + pushedIntMask = pushedIntMask or mask + } + val base = intArgsSize - peekOperation().ints + val intArgs = intArgs + intArgs[base + parameter1] = value1 + intArgs[base + parameter2] = value2 + intArgs[base + parameter3] = value3 + } + fun setObject(parameter: ObjectParameter, value: T) = with(stack) { - val mask = 0b1 shl parameter.offset - checkPrecondition(pushedObjectMask and mask == 0) { - "Already pushed argument ${operation.objectParamName(parameter)}" + if (EnableDebugRuntimeChecks) { + val mask = 0b1 shl parameter.offset + debugRuntimeCheck(pushedObjectMask and mask == 0) { + "Already pushed argument ${operation.objectParamName(parameter)}" + } + pushedObjectMask = pushedObjectMask or mask } - pushedObjectMask = pushedObjectMask or mask objectArgs[topObjectIndexOf(parameter)] = value } + + fun setObjects( + parameter1: ObjectParameter, + value1: T, + parameter2: ObjectParameter, + value2: U + ) = + with(stack) { + if (EnableDebugRuntimeChecks) { + val mask = (0b1 shl parameter1.offset) or (0b1 shl parameter2.offset) + debugRuntimeCheck(pushedIntMask and mask == 0) { + "Already pushed argument(s) ${operation.objectParamName(parameter1)}" + + ", ${operation.objectParamName(parameter2)}" + } + pushedIntMask = pushedIntMask or mask + } + val base = objectArgsSize - peekOperation().objects + val objectArgs = objectArgs + objectArgs[base + parameter1.offset] = value1 + objectArgs[base + parameter2.offset] = value2 + } + + fun setObjects( + parameter1: ObjectParameter, + value1: T, + parameter2: ObjectParameter, + value2: U, + parameter3: ObjectParameter, + value3: V + ) = + with(stack) { + if (EnableDebugRuntimeChecks) { + val mask = + (0b1 shl parameter1.offset) or + (0b1 shl parameter2.offset) or + (0b1 shl parameter3.offset) + debugRuntimeCheck(pushedIntMask and mask == 0) { + "Already pushed argument(s) ${operation.objectParamName(parameter1)}" + + ", ${operation.objectParamName(parameter2)}" + + ", ${operation.objectParamName(parameter3)}" + } + pushedIntMask = pushedIntMask or mask + } + val base = objectArgsSize - peekOperation().objects + val objectArgs = objectArgs + objectArgs[base + parameter1.offset] = value1 + objectArgs[base + parameter2.offset] = value2 + objectArgs[base + parameter3.offset] = value3 + } + + fun setObjects( + parameter1: ObjectParameter, + value1: T, + parameter2: ObjectParameter, + value2: U, + parameter3: ObjectParameter, + value3: V, + parameter4: ObjectParameter, + value4: W + ) = + with(stack) { + if (EnableDebugRuntimeChecks) { + val mask = + (0b1 shl parameter1.offset) or + (0b1 shl parameter2.offset) or + (0b1 shl parameter3.offset) or + (0b1 shl parameter4.offset) + debugRuntimeCheck(pushedIntMask and mask == 0) { + "Already pushed argument(s) ${operation.objectParamName(parameter1)}" + + ", ${operation.objectParamName(parameter2)}" + + ", ${operation.objectParamName(parameter3)}" + + ", ${operation.objectParamName(parameter4)}" + } + pushedIntMask = pushedIntMask or mask + } + val base = objectArgsSize - peekOperation().objects + val objectArgs = objectArgs + objectArgs[base + parameter1.offset] = value1 + objectArgs[base + parameter2.offset] = value2 + objectArgs[base + parameter3.offset] = value3 + objectArgs[base + parameter4.offset] = value4 + } } inner class OpIterator : OperationArgContainer { @@ -351,13 +535,13 @@ internal class Operations : OperationsDebugStringFormattable() { /** Returns the [Operation] at the current position of the iterator in the [Operations]. */ val operation: Operation - get() = opCodes[opIdx]!! + get() = opCodes[opIdx] /** * Returns the value of [parameter] for the operation at the current position of the * iterator. */ - override fun getInt(parameter: IntParameter): Int = intArgs[intIdx + parameter.offset] + override fun getInt(parameter: IntParameter): Int = intArgs[intIdx + parameter] /** * Returns the value of [parameter] for the operation at the current position of the @@ -366,11 +550,14 @@ internal class Operations : OperationsDebugStringFormattable() { @Suppress("UNCHECKED_CAST") override fun getObject(parameter: ObjectParameter): T = objectArgs[objIdx + parameter.offset] as T - } - companion object { - private const val MaxResizeAmount = 1024 - internal const val InitialCapacity = 16 + @Suppress("UNUSED") + fun currentOperationDebugString() = buildString { + append("operation[") + append(opIdx) + append("] = ") + append(currentOpToDebugString("")) + } } @Deprecated( @@ -384,7 +571,7 @@ internal class Operations : OperationsDebugStringFormattable() { override fun toDebugString(linePrefix: String): String { return buildString { - var opNumber = 1 + var opNumber = 0 this@Operations.forEach { append(linePrefix) append(opNumber++) @@ -405,17 +592,16 @@ internal class Operations : OperationsDebugStringFormattable() { var isFirstParam = true val argLinePrefix = linePrefix.indent() repeat(operation.ints) { offset -> - val param = Operation.IntParameter(offset) - val name = operation.intParamName(param) + val name = operation.intParamName(offset) if (!isFirstParam) append(", ") else isFirstParam = false appendLine() append(argLinePrefix) append(name) append(" = ") - append(getInt(param)) + append(getInt(offset)) } repeat(operation.objects) { offset -> - val param = Operation.ObjectParameter(offset) + val param = ObjectParameter(offset) val name = operation.objectParamName(param) if (!isFirstParam) append(", ") else isFirstParam = false appendLine() diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/Extensions.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/Extensions.kt new file mode 100644 index 0000000000000..ee41430abf63a --- /dev/null +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/Extensions.kt @@ -0,0 +1,71 @@ +/* + * 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. + */ + +package androidx.compose.runtime.collection + +import androidx.collection.MutableObjectList +import androidx.collection.ObjectList + +internal inline fun ObjectList.fastMap(transform: (T) -> R): ObjectList { + val target = MutableObjectList(size) + forEach { target += transform(it) } + return target +} + +internal inline fun ObjectList.fastFilter(predicate: (T) -> Boolean): ObjectList { + if (all(predicate)) return this + val target = MutableObjectList() + forEach { if (predicate(it)) target += it } + return target +} + +internal inline fun ObjectList.all(predicate: (T) -> Boolean): Boolean { + forEach { if (!predicate(it)) return false } + return true +} + +internal fun ObjectList.toMutableObjectList(): MutableObjectList { + val target = MutableObjectList(size) + forEach { target += it } + return target +} + +internal fun > ObjectList.sortedBy(selector: (T) -> K?): ObjectList = + if (isSorted(selector)) this else toMutableObjectList().also { it.sortBy(selector) } + +internal fun > ObjectList.isSorted(selector: (T) -> K?): Boolean { + if (size <= 1) return true + val previousValue = get(0) + var previousKey = selector(previousValue) ?: return false + for (i in 1 until size) { + val value = get(i) + val key = selector(value) ?: return false + if (previousKey > key) return false + previousKey = key + } + return true +} + +internal fun > MutableObjectList.sortBy(selector: (T) -> K?) { + @Suppress("AsCollectionCall") // Needed to call sortBy + asMutableList().sortBy(selector) +} + +internal fun MutableObjectList.removeLast(): T { + if (isEmpty()) throw NoSuchElementException("List is empty.") + val last = size - 1 + return this[last].also { removeAt(last) } +} diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MultiValueMap.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MultiValueMap.kt new file mode 100644 index 0000000000000..cbc28f18db813 --- /dev/null +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MultiValueMap.kt @@ -0,0 +1,140 @@ +/* + * 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. + */ + +package androidx.compose.runtime.collection + +import androidx.collection.MutableObjectList +import androidx.collection.MutableScatterMap +import androidx.collection.ObjectList +import androidx.collection.emptyObjectList +import androidx.collection.mutableObjectListOf +import androidx.collection.objectListOf +import androidx.compose.runtime.debugRuntimeCheck +import kotlin.jvm.JvmInline + +/** + * A mutable multi-map of values. + * + * Warning: the type constraints are insufficient to adequately describe the limitations of this + * class. This can only be used if V is not nullable and V is not a MutableList<*> or can be + * implemented by one (i.e. for all instances of v: V there is no v for which v is MutableList<*> is + * true). + */ +@JvmInline +@Suppress("UNCHECKED_CAST") +internal value class MultiValueMap( + private val map: MutableScatterMap = MutableScatterMap() +) { + fun add(key: K, value: V) { + // Only create a list if there more than one value is stored in the map. Otherwise, + // the value is stored in the map directly. This only works if V is not a MutableList<*>. + map.compute(key) { _, previous -> + debugRuntimeCheck(previous !is MutableList<*>) { "Unexpected value" } + when (previous) { + null -> value + is MutableObjectList<*> -> { + val list = previous as MutableObjectList + list.add(value) + list + } + else -> mutableObjectListOf(previous, value) + } + } + } + + fun clear() = map.clear() + + operator fun contains(key: K) = key in map + + operator fun get(key: K): ObjectList = + when (val entry = map[key]) { + null -> emptyObjectList() + is MutableObjectList<*> -> entry as ObjectList + else -> objectListOf(entry as V) + } + + fun isEmpty() = map.isEmpty() + + fun isNotEmpty() = map.isNotEmpty() + + fun removeLast(key: K): V? = + when (val entry = map[key]) { + null -> null + is MutableObjectList<*> -> { + val list = entry as MutableObjectList + val result = list.removeLast() as V + if (list.isEmpty()) map.remove(key) + if (list.size == 1) map[key] = list.first() + result + } + else -> { + map.remove(key) + entry as V + } + } + + fun removeFirst(key: K): V? = + when (val entry = map[key]) { + null -> null + is MutableObjectList<*> -> { + val list = entry as MutableObjectList + val result = list.removeAt(0) + if (list.isEmpty()) map.remove(key) + if (list.size == 1) map[key] = list.first() + result + } + else -> { + map.remove(key) + entry as V + } + } + + fun values(): ObjectList { + if (map.isEmpty()) return emptyObjectList() + val result = mutableObjectListOf() + map.forEachValue { entry -> + when (entry) { + is MutableObjectList<*> -> result.addAll(entry as MutableObjectList) + else -> result.add(entry as V) + } + } + return result + } + + inline fun forEachValue(key: K, block: (value: V) -> Unit) { + map[key]?.let { + when (it) { + is MutableObjectList<*> -> { + it.forEach { value -> block(value as V) } + } + else -> block(it as V) + } + } + } + + fun removeValueIf(key: K, condition: (value: V) -> Boolean) { + map[key]?.let { + when (it) { + is MutableObjectList<*> -> { + (it as MutableObjectList).removeIf(condition) + if (it.isEmpty()) map.remove(key) + if (it.size == 0) map[key] = it.first() + } + else -> if (condition(it as V)) map.remove(key) + } + } + } +} diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt index 62c975fba57f5..1315b328cabd5 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/MutableVector.kt @@ -14,12 +14,13 @@ * limitations under the License. */ -@file:Suppress("NOTHING_TO_INLINE", "UNCHECKED_CAST") +@file:Suppress("NOTHING_TO_INLINE", "UNCHECKED_CAST", "KotlinRedundantDiagnosticSuppress") package androidx.compose.runtime.collection import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract +import kotlin.jvm.JvmField import kotlin.math.max /** @@ -29,7 +30,8 @@ import kotlin.math.max @OptIn(ExperimentalContracts::class) class MutableVector @PublishedApi -internal constructor(@PublishedApi internal var content: Array, size: Int) : RandomAccess { +internal constructor(@PublishedApi @JvmField internal var content: Array, size: Int) : + RandomAccess { /** Stores allocated [MutableList] representation of this vector. */ private var list: MutableList? = null @@ -45,6 +47,9 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : inline val indices: IntRange get() = 0 until size + // Added for compatibility with `content` previously defined without @JvmField + @PublishedApi internal fun getContent() = content + /** Adds [element] to the [MutableVector] and returns `true`. */ fun add(element: T): Boolean { ensureCapacity(size + 1) @@ -183,14 +188,10 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : /** Returns `true` if any of the elements give a `true` return value for [predicate]. */ inline fun any(predicate: (T) -> Boolean): Boolean { contract { callsInPlace(predicate) } + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - if (predicate(content[i])) return true - i++ - } while (i < size) + for (i in 0 until size) { + if (predicate(content[i])) return true } return false } @@ -201,14 +202,11 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun reversedAny(predicate: (T) -> Boolean): Boolean { contract { callsInPlace(predicate) } - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - if (predicate(content[i])) return true - i-- - } while (i >= 0) + val content = content as Array + var i = size - 1 + while (i >= 0) { + if (predicate(content[i])) return true + i-- } return false } @@ -221,7 +219,7 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : /** Removes all elements in the [MutableVector]. */ fun clear() { val content = content - for (i in lastIndex downTo 0) { + for (i in 0 until size) { content[i] = null } size = 0 @@ -283,14 +281,22 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : } /** Ensures that there is enough space to store [capacity] elements in the [MutableVector]. */ - fun ensureCapacity(capacity: Int) { - val oldContent = content - if (oldContent.size < capacity) { - val newSize = max(capacity, oldContent.size * 2) - content = oldContent.copyOf(newSize) + inline fun ensureCapacity(capacity: Int) { + if (content.size < capacity) { + resizeStorage(capacity) } } + @PublishedApi + internal fun resizeStorage(capacity: Int) { + val oldContent = content + val oldSize = oldContent.size + val newSize = max(capacity, oldSize * 2) + val newContent = arrayOfNulls(newSize) as Array + oldContent.copyInto(newContent, 0, 0, oldSize) + content = newContent + } + /** * Returns the first element in the [MutableVector] or throws a [NoSuchElementException] if it * [isEmpty]. @@ -308,15 +314,11 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun first(predicate: (T) -> Boolean): T { contract { callsInPlace(predicate) } + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - val item = content[i] - if (predicate(item)) return item - i++ - } while (i < size) + for (i in 0 until size) { + val item = content[i] + if (predicate(item)) return item } throwNoSuchElementException("MutableVector contains no element matching the predicate.") } @@ -339,15 +341,11 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun firstOrNull(predicate: (T) -> Boolean): T? { contract { callsInPlace(predicate) } + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - val item = content[i] - if (predicate(item)) return item - i++ - } while (i < size) + for (i in 0 until size) { + val item = content[i] + if (predicate(item)) return item } return null } @@ -359,14 +357,10 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : inline fun fold(initial: R, operation: (acc: R, T) -> R): R { contract { callsInPlace(operation) } var acc = initial + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - acc = operation(acc, content[i]) - i++ - } while (i < size) + for (i in 0 until size) { + acc = operation(acc, content[i]) } return acc } @@ -378,14 +372,10 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : inline fun foldIndexed(initial: R, operation: (index: Int, acc: R, T) -> R): R { contract { callsInPlace(operation) } var acc = initial + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - acc = operation(i, acc, content[i]) - i++ - } while (i < size) + for (i in 0 until size) { + acc = operation(i, acc, content[i]) } return acc } @@ -397,14 +387,12 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : inline fun foldRight(initial: R, operation: (T, acc: R) -> R): R { contract { callsInPlace(operation) } var acc = initial - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - acc = operation(content[i], acc) - i-- - } while (i >= 0) + var i = size - 1 + val content = content as Array + if (i >= content.size) return acc + while (i >= 0) { + acc = operation(content[i], acc) + i-- } return acc } @@ -416,14 +404,12 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : inline fun foldRightIndexed(initial: R, operation: (index: Int, T, acc: R) -> R): R { contract { callsInPlace(operation) } var acc = initial - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - acc = operation(i, content[i], acc) - i-- - } while (i >= 0) + var i = size - 1 + val content = content as Array + if (i >= content.size) return acc + while (i >= 0) { + acc = operation(i, content[i], acc) + i-- } return acc } @@ -431,42 +417,36 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : /** Calls [block] for each element in the [MutableVector], in order. */ inline fun forEach(block: (T) -> Unit) { contract { callsInPlace(block) } + var i = 0 + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - block(content[i]) - i++ - } while (i < size) + while (i < size) { + block(content[i]) + i++ } } /** Calls [block] for each element in the [MutableVector] along with its index, in order. */ inline fun forEachIndexed(block: (Int, T) -> Unit) { contract { callsInPlace(block) } + var i = 0 + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - block(i, content[i]) - i++ - } while (i < size) + while (i < size) { + block(i, content[i]) + i++ } } /** Calls [block] for each element in the [MutableVector] in reverse order. */ inline fun forEachReversed(block: (T) -> Unit) { contract { callsInPlace(block) } - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - block(content[i]) - i-- - } while (i >= 0) + var i = size - 1 + val content = content as Array + if (i >= content.size) return + while (i >= 0) { + block(content[i]) + i-- } } @@ -475,13 +455,12 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun forEachReversedIndexed(block: (Int, T) -> Unit) { contract { callsInPlace(block) } - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - block(i, content[i]) - i-- - } while (i >= 0) + var i = size - 1 + val content = content as Array + if (i >= content.size) return + while (i >= 0) { + block(i, content[i]) + i-- } } @@ -490,14 +469,10 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : /** Returns the index of [element] in the [MutableVector] or `-1` if [element] is not there. */ fun indexOf(element: T): Int { + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - if (element == content[i]) return i - i++ - } while (i < size) + for (i in 0 until size) { + if (element == content[i]) return i } return -1 } @@ -508,14 +483,10 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun indexOfFirst(predicate: (T) -> Boolean): Int { contract { callsInPlace(predicate) } + val content = content as Array val size = size - if (size > 0) { - var i = 0 - val content = content as Array - do { - if (predicate(content[i])) return i - i++ - } while (i < size) + for (i in 0 until size) { + if (predicate(content[i])) return i } return -1 } @@ -526,14 +497,13 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun indexOfLast(predicate: (T) -> Boolean): Int { contract { callsInPlace(predicate) } - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { + var i = size - 1 + val content = content as Array + if (i < content.size) { + while (i >= 0) { if (predicate(content[i])) return i i-- - } while (i >= 0) + } } return -1 } @@ -561,15 +531,12 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun last(predicate: (T) -> Boolean): T { contract { callsInPlace(predicate) } - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - val item = content[i] - if (predicate(item)) return item - i-- - } while (i >= 0) + var i = size - 1 + val content = content as Array + while (i >= 0) { + val item = content[i] + if (predicate(item)) return item + i-- } throwNoSuchElementException("MutableVector contains no element matching the predicate.") } @@ -579,14 +546,11 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : * `-1` if no elements match. */ fun lastIndexOf(element: T): Int { - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - if (element == content[i]) return i - i-- - } while (i >= 0) + var i = size - 1 + val content = content as Array + while (i >= 0) { + if (element == content[i]) return i + i-- } return -1 } @@ -600,15 +564,12 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : */ inline fun lastOrNull(predicate: (T) -> Boolean): T? { contract { callsInPlace(predicate) } - val size = size - if (size > 0) { - var i = size - 1 - val content = content as Array - do { - val item = content[i] - if (predicate(item)) return item - i-- - } while (i >= 0) + var i = size - 1 + val content = content as Array + while (i >= 0) { + val item = content[i] + if (predicate(item)) return item + i-- } return null } @@ -642,16 +603,12 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : val size = size val arr = arrayOfNulls(size) var targetSize = 0 - if (size > 0) { - val content = content as Array - var i = 0 - do { - val target = transform(i, content[i]) - if (target != null) { - arr[targetSize++] = target - } - i++ - } while (i < size) + val content = content as Array + for (i in 0 until size) { + val target = transform(i, content[i]) + if (target != null) { + arr[targetSize++] = target + } } return MutableVector(arr, targetSize) } @@ -665,16 +622,12 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : val size = size val arr = arrayOfNulls(size) var targetSize = 0 - if (size > 0) { - val content = content as Array - var i = 0 - do { - val target = transform(content[i]) - if (target != null) { - arr[targetSize++] = target - } - i++ - } while (i < size) + val content = content as Array + for (i in 0 until size) { + val target = transform(content[i]) + if (target != null) { + arr[targetSize++] = target + } } return MutableVector(arr, targetSize) } @@ -827,14 +780,11 @@ internal constructor(@PublishedApi internal var content: Array, size: Int) : inline fun sumBy(selector: (T) -> Int): Int { contract { callsInPlace(selector) } var sum = 0 - val size = size - if (size > 0) { - val content = content as Array - var i = 0 - do { - sum += selector(content[i]) - i++ - } while (i < size) + val content = content as Array + var i = 0 + while (i < size) { + sum += selector(content[i]) + i++ } return sum } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/RememberEventDispatcher.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/RememberEventDispatcher.kt index d9d78eaf0203c..ac7656397836b 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/RememberEventDispatcher.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/RememberEventDispatcher.kt @@ -26,7 +26,8 @@ import androidx.compose.runtime.RecomposeScopeImpl import androidx.compose.runtime.RememberManager import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.Stack -import androidx.compose.runtime.snapshots.fastForEach +import androidx.compose.runtime.collection.MutableVector +import androidx.compose.runtime.collection.mutableVectorOf /** * Used as a placeholder for paused compositions to ensure the remembers are dispatch in the correct @@ -37,10 +38,10 @@ import androidx.compose.runtime.snapshots.fastForEach */ internal class PausedCompositionRemembers(private val abandoning: MutableSet) : RememberObserver { - val pausedRemembers = mutableListOf() + val pausedRemembers = mutableVectorOf() override fun onRemembered() { - pausedRemembers.fastForEach { + pausedRemembers.forEach { abandoning.remove(it) it.onRemembered() } @@ -55,10 +56,10 @@ internal class PausedCompositionRemembers(private val abandoning: MutableSet) : RememberManager { - private val remembering = mutableListOf() + private val remembering = mutableVectorOf() private var currentRememberingList = remembering - private val leaving = mutableListOf() - private val sideEffects = mutableListOf<() -> Unit>() + private val leaving = mutableVectorOf() + private val sideEffects = mutableVectorOf<() -> Unit>() private var releasing: MutableScatterSet? = null private var pausedPlaceholders: MutableScatterMap? = @@ -66,7 +67,7 @@ internal class RememberEventDispatcher(private val abandoning: MutableSet() private val priorities = MutableIntList() private val afters = MutableIntList() - private var nestedRemembersLists: Stack>? = null + private var nestedRemembersLists: Stack>? = null override fun remembering(instance: RememberObserver) { currentRememberingList.add(instance) @@ -120,7 +121,7 @@ internal class RememberEventDispatcher(private val abandoning: MutableSet>().also { nestedRemembersLists = it }) + ?: Stack>().also { nestedRemembersLists = it }) .push(currentRememberingList) currentRememberingList = placeholder.pausedRemembers } @@ -169,8 +170,8 @@ internal class RememberEventDispatcher(private val abandoning: MutableSet) { - list.fastForEach { instance -> + private fun dispatchRememberList(list: MutableVector) { + list.forEach { instance -> abandoning.remove(instance) instance.onRemembered() } @@ -179,7 +180,7 @@ internal class RememberEventDispatcher(private val abandoning: MutableSet sideEffect() } + sideEffects.forEach { sideEffect -> sideEffect() } sideEffects.clear() } } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/SnapshotThreadLocal.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/SnapshotThreadLocal.kt index f379b1cdb4f40..e38919124b97a 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/SnapshotThreadLocal.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/internal/SnapshotThreadLocal.kt @@ -16,8 +16,8 @@ package androidx.compose.runtime.internal -import androidx.compose.runtime.SynchronizedObject -import androidx.compose.runtime.synchronized +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized /** * This is similar to a [ThreadLocal] but has lower overhead because it avoids a weak reference. @@ -30,7 +30,7 @@ import androidx.compose.runtime.synchronized */ internal class SnapshotThreadLocal { private val map = AtomicReference(emptyThreadMap) - private val writeMutex = SynchronizedObject() + private val writeMutex = makeSynchronizedObject() private var mainThreadValue: T? = null diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SynchronizedObject.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/platform/Synchronization.kt similarity index 64% rename from compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SynchronizedObject.kt rename to compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/platform/Synchronization.kt index fb289c8d410eb..7b427ee1daf94 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SynchronizedObject.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/platform/Synchronization.kt @@ -14,9 +14,16 @@ * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.platform -internal expect class SynchronizedObject() +internal expect class SynchronizedObject + +/** + * Returns [ref] as a [SynchronizedObject] on platforms where [Any] is a valid [SynchronizedObject], + * or a new [SynchronizedObject] instance if [ref] is null or this is not supported on the current + * platform. + */ +internal expect inline fun makeSynchronizedObject(ref: Any? = null): SynchronizedObject @PublishedApi internal expect inline fun synchronized(lock: SynchronizedObject, block: () -> R): R diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt index e593c27bb5691..942a63ae8d9ec 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.compose.runtime.InternalComposeApi -import androidx.compose.runtime.SynchronizedObject import androidx.compose.runtime.checkPrecondition import androidx.compose.runtime.collection.wrapIntoSet import androidx.compose.runtime.internal.AtomicInt @@ -30,13 +29,14 @@ import androidx.compose.runtime.internal.AtomicReference import androidx.compose.runtime.internal.JvmDefaultWithCompatibility import androidx.compose.runtime.internal.SnapshotThreadLocal import androidx.compose.runtime.internal.currentThreadId +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import androidx.compose.runtime.requirePrecondition import androidx.compose.runtime.snapshots.Snapshot.Companion.takeMutableSnapshot import androidx.compose.runtime.snapshots.Snapshot.Companion.takeSnapshot import androidx.compose.runtime.snapshots.tooling.creatingSnapshot import androidx.compose.runtime.snapshots.tooling.dispatchObserverOnApplied import androidx.compose.runtime.snapshots.tooling.dispatchObserverOnDispose -import androidx.compose.runtime.synchronized import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -60,16 +60,31 @@ import kotlin.contracts.contract * @see androidx.compose.runtime.mutableStateMapOf */ sealed class Snapshot( - id: Int, + snapshotId: SnapshotId, /** A set of all the snapshots that should be treated as invalid. */ internal open var invalid: SnapshotIdSet ) { + @Deprecated("Use id: Long constructor instead", level = DeprecationLevel.HIDDEN) + constructor(id: Int, invalid: SnapshotIdSet) : this(id.toSnapshotId(), invalid) + + /** + * The snapshot id of the snapshot. This is a unique number from a monotonically increasing + * value for each snapshot taken. + * + * [id] will is identical to [snapshotId] if the value of [snapshotId] is less than or equal to + * [Int.MAX_VALUE]. For [snapshotId] value greater than [Int.MAX_VALUE], this value will return + * a negative value. + */ + @Deprecated("Use snapshotId instead", replaceWith = ReplaceWith("snapshotId")) + open val id: Int + get() = snapshotId.toInt() + /** * The snapshot id of the snapshot. This is a unique number from a monotonically increasing * value for each snapshot taken. */ - open var id: Int = id + open var snapshotId: SnapshotId = snapshotId internal set internal open var writeCount: Int @@ -185,7 +200,7 @@ sealed class Snapshot( */ @Suppress("LeakingThis") private var pinningTrackingHandle = - if (id != INVALID_SNAPSHOT) trackPinning(id, invalid) else -1 + if (snapshotId != INVALID_SNAPSHOT) trackPinning(snapshotId, invalid) else -1 internal inline val isPinned get() = pinningTrackingHandle >= 0 @@ -241,7 +256,7 @@ sealed class Snapshot( * Call while holding a `sync {}` lock. */ internal open fun closeLocked() { - openSnapshots = openSnapshots.clear(id) + openSnapshots = openSnapshots.clear(snapshotId) } /** @@ -687,8 +702,8 @@ sealed class Snapshot( * @return returns a handle that should be passed to [releasePinningLocked] when the snapshot closes * or is disposed. */ -internal fun trackPinning(id: Int, invalid: SnapshotIdSet): Int { - val pinned = invalid.lowest(id) +internal fun trackPinning(snapshotId: SnapshotId, invalid: SnapshotIdSet): Int { + val pinned = invalid.lowest(snapshotId) return sync { pinningTable.add(pinned) } } @@ -720,11 +735,11 @@ internal fun releasePinningLocked(handle: Int) { */ open class MutableSnapshot internal constructor( - id: Int, + snapshotId: SnapshotId, invalid: SnapshotIdSet, override val readObserver: ((Any) -> Unit)?, override val writeObserver: ((Any) -> Unit)? -) : Snapshot(id, invalid) { +) : Snapshot(snapshotId, invalid) { /** * Whether there are any pending changes in this snapshot. These changes are not visible until * the snapshot is applied. @@ -756,13 +771,14 @@ internal constructor( actualWriteObserver -> advance { sync { - val newId = nextSnapshotId++ + val newId = nextSnapshotId + nextSnapshotId += 1 openSnapshots = openSnapshots.set(newId) val currentInvalid = invalid this.invalid = currentInvalid.set(newId) NestedMutableSnapshot( newId, - currentInvalid.addRange(id + 1, newId), + currentInvalid.addRange(snapshotId + 1, newId), mergedReadObserver(actualReadObserver, this.readObserver), mergedWriteObserver(actualWriteObserver, this.writeObserver), this @@ -805,7 +821,7 @@ internal constructor( optimisticMerges( currentGlobalSnapshot.get(), this, - openSnapshots.clear(currentGlobalSnapshot.get().id) + openSnapshots.clear(currentGlobalSnapshot.get().snapshotId) ) else null @@ -829,7 +845,7 @@ internal constructor( nextSnapshotId, modified, optimisticMerges, - openSnapshots.clear(previousGlobalSnapshot.id) + openSnapshots.clear(previousGlobalSnapshot.snapshotId) ) if (result != SnapshotApplyResult.Success) return result @@ -897,7 +913,7 @@ internal constructor( override fun takeNestedSnapshot(readObserver: ((Any) -> Unit)?): Snapshot { validateNotDisposed() validateNotAppliedOrPinned() - val previousId = id + val previousId = snapshotId return creatingSnapshot( if (this is GlobalSnapshot) null else this, readObserver = readObserver, @@ -906,10 +922,10 @@ internal constructor( ) { actualReadObserver, _ -> advance { sync { - val readonlyId = nextSnapshotId++ + val readonlyId = nextSnapshotId.also { nextSnapshotId += 1 } openSnapshots = openSnapshots.set(readonlyId) NestedReadonlySnapshot( - id = readonlyId, + snapshotId = readonlyId, invalid = invalid.addRange(previousId + 1, readonlyId), readObserver = mergedReadObserver(actualReadObserver, this.readObserver), parent = this @@ -939,7 +955,7 @@ internal constructor( override fun closeLocked() { // Remove itself and previous ids from the open set. - openSnapshots = openSnapshots.clear(id).andNot(previousIds) + openSnapshots = openSnapshots.clear(snapshotId).andNot(previousIds) } override fun releasePinnedSnapshotsForCloseLocked() { @@ -969,7 +985,7 @@ internal constructor( // Mark all state records created in this snapshot as invalid. This allows the snapshot // id to be forgotten as no state records will refer to it. this.modified = null - val id = id + val id = snapshotId modified.forEach { state -> var current: StateRecord? = state.firstStateRecord while (current != null) { @@ -986,7 +1002,7 @@ internal constructor( } internal fun innerApplyLocked( - snapshotId: Int, + nextId: SnapshotId, modified: MutableScatterSet, optimisticMerges: Map?, invalidSnapshots: SnapshotIdSet @@ -1003,15 +1019,15 @@ internal constructor( // object is asked if it can resolve the collision. If it can the updated state record // is for the apply. var mergedRecords: MutableList>? = null - val start = this.invalid.set(id).or(this.previousIds) + val start = this.invalid.set(this.snapshotId).or(this.previousIds) var statesToRemove: MutableList? = null modified.forEach { state -> val first = state.firstStateRecord // If either current or previous cannot be calculated the object was created // in a nested snapshot that was committed then changed. - val current = readable(first, snapshotId, invalidSnapshots) ?: return@forEach - val previous = readable(first, id, start) ?: return@forEach - if (previous.snapshotId == PreexistingSnapshotId) { + val current = readable(first, nextId, invalidSnapshots) ?: return@forEach + val previous = readable(first, this.snapshotId, start) ?: return@forEach + if (previous.snapshotId == PreexistingSnapshotId.toSnapshotId()) { // A previous record might not be found if the state object was created in a // nested snapshot that didn't have any other modifications. The `apply()` for // a nested snapshot considers such snapshots no-op snapshots and just closes them @@ -1021,7 +1037,7 @@ internal constructor( return@forEach } if (current != previous) { - val applied = readable(first, id, this.invalid) ?: readError() + val applied = readable(first, this.snapshotId, this.invalid) ?: readError() val merged = optimisticMerges?.get(current) ?: run { state.mergeRecords(previous, current, applied) } @@ -1036,7 +1052,7 @@ internal constructor( ?: mutableListOf>().also { mergedRecords = it }) - .add(state to current.create()) + .add(state to current.create(snapshotId)) // If we revert to current then the state is no longer modified. (statesToRemove @@ -1050,7 +1066,7 @@ internal constructor( }) .add( if (merged != previous) state to merged - else state to previous.create() + else state to previous.create(snapshotId) ) } } @@ -1064,7 +1080,7 @@ internal constructor( // Update all the merged records to have the new id. it.fastForEach { merged -> val (state, stateRecord) = merged - stateRecord.snapshotId = id + stateRecord.snapshotId = nextId sync { stateRecord.next = state.firstStateRecord state.prependStateRecord(stateRecord) @@ -1082,26 +1098,26 @@ internal constructor( } internal inline fun advance(block: () -> T): T { - recordPrevious(id) + recordPrevious(snapshotId) return block().also { // Only advance this snapshot if it's possible for it to be applied later, // otherwise we don't need to bother. // This simplifies tracking of open snapshots when an apply observer takes // a nested snapshot of the snapshot that was just applied. if (!applied && !disposed) { - val previousId = id + val previousId = snapshotId sync { - id = nextSnapshotId++ - openSnapshots = openSnapshots.set(id) + snapshotId = nextSnapshotId.also { nextSnapshotId += 1 } + openSnapshots = openSnapshots.set(snapshotId) } - invalid = invalid.addRange(previousId + 1, id) + invalid = invalid.addRange(previousId + 1, snapshotId) } } } internal fun advance(): Unit = advance {} - internal fun recordPrevious(id: Int) { + internal fun recordPrevious(id: SnapshotId) { sync { previousIds = previousIds.set(id) } } @@ -1225,9 +1241,14 @@ internal fun currentSnapshot(): Snapshot = threadSnapshot.get() ?: currentGlobal class SnapshotApplyConflictException(@Suppress("unused") val snapshot: Snapshot) : Exception() /** Snapshot local value of a state object. */ -abstract class StateRecord { +abstract class StateRecord( /** The snapshot id of the snapshot in which the record was created. */ - internal var snapshotId: Int = currentSnapshot().id + internal var snapshotId: SnapshotId +) { + constructor() : this(currentSnapshot().snapshotId) + + @Deprecated("Use snapshotId: Long constructor instead") + constructor(id: Int) : this(id.toSnapshotId()) /** * Reference of the next state record. State records are stored in a linked list. @@ -1248,8 +1269,30 @@ abstract class StateRecord { /** Copy the value into this state record from another for the same state object. */ abstract fun assign(value: StateRecord) - /** Create a new state record for the same state object. */ + /** + * Create a new state record for the same state object. Consider also implementing the [create] + * overload that provides snapshotId for faster record construction when snapshot id is known. + */ abstract fun create(): StateRecord + + /** + * Create a new state record for the same state object and provided [snapshotId]. This allows to + * implement an optimized version of [create] to avoid accessing [currentSnapshot] when snapshot + * id is known. The default implementation provides a backwards compatible behavior, and should + * be overridden if [StateRecord] subclass supports this optimization. + */ + @Deprecated("Use snapshotId: Long version instead", level = DeprecationLevel.HIDDEN) + open fun create(snapshotId: Int): StateRecord = + create().also { it.snapshotId = snapshotId.toSnapshotId() } + + /** + * Create a new state record for the same state object and provided [snapshotId]. This allows to + * implement an optimized version of [create] to avoid accessing [currentSnapshot] when snapshot + * id is known. The default implementation provides a backwards compatible behavior, and should + * be overridden if [StateRecord] subclass supports this optimization. + */ + open fun create(snapshotId: SnapshotId): StateRecord = + create().also { it.snapshotId = snapshotId } } /** @@ -1296,8 +1339,11 @@ interface StateObject { * read-only snapshot a [IllegalStateException] is thrown. */ internal class ReadonlySnapshot -internal constructor(id: Int, invalid: SnapshotIdSet, override val readObserver: ((Any) -> Unit)?) : - Snapshot(id, invalid) { +internal constructor( + snapshotId: SnapshotId, + invalid: SnapshotIdSet, + override val readObserver: ((Any) -> Unit)? +) : Snapshot(snapshotId, invalid) { /** * The number of nested snapshots that are active. To simplify the code, this snapshot counts * itself as a nested snapshot. @@ -1328,7 +1374,7 @@ internal constructor(id: Int, invalid: SnapshotIdSet, override val readObserver: readonly = true, ) { actualReadObserver, _ -> NestedReadonlySnapshot( - id = id, + snapshotId = snapshotId, invalid = invalid, readObserver = mergedReadObserver(actualReadObserver, this.readObserver), parent = this @@ -1365,11 +1411,11 @@ internal constructor(id: Int, invalid: SnapshotIdSet, override val readObserver: } internal class NestedReadonlySnapshot( - id: Int, + snapshotId: SnapshotId, invalid: SnapshotIdSet, override val readObserver: ((Any) -> Unit)?, val parent: Snapshot -) : Snapshot(id, invalid) { +) : Snapshot(snapshotId, invalid) { init { parent.nestedActivated(this) } @@ -1389,7 +1435,7 @@ internal class NestedReadonlySnapshot( readonly = true, ) { actualReadObserver, _ -> NestedReadonlySnapshot( - id = id, + snapshotId = snapshotId, invalid = invalid, readObserver = mergedReadObserver(actualReadObserver, this.readObserver), parent = parent @@ -1404,7 +1450,7 @@ internal class NestedReadonlySnapshot( override fun dispose() { if (!disposed) { - if (id != parent.id) { + if (snapshotId != parent.snapshotId) { closeAndReleasePinning() } parent.nestedDeactivated(this) @@ -1431,9 +1477,9 @@ private val emptyLambda: (invalid: SnapshotIdSet) -> Unit = {} /** * A snapshot object that simplifies the code by treating the global state as a mutable snapshot. */ -internal class GlobalSnapshot(id: Int, invalid: SnapshotIdSet) : +internal class GlobalSnapshot(snapshotId: SnapshotId, invalid: SnapshotIdSet) : MutableSnapshot( - id, + snapshotId, invalid, null, sync { @@ -1453,7 +1499,7 @@ internal class GlobalSnapshot(id: Int, invalid: SnapshotIdSet) : ) { actualReadObserver, _ -> takeNewSnapshot { invalid -> ReadonlySnapshot( - id = sync { nextSnapshotId++ }, + snapshotId = sync { nextSnapshotId.also { nextSnapshotId += 1 } }, invalid = invalid, readObserver = actualReadObserver ) @@ -1473,7 +1519,7 @@ internal class GlobalSnapshot(id: Int, invalid: SnapshotIdSet) : ) { actualReadObserver, actualWriteObserver -> takeNewSnapshot { invalid -> MutableSnapshot( - id = sync { nextSnapshotId++ }, + snapshotId = sync { nextSnapshotId.also { nextSnapshotId += 1 } }, invalid = invalid, // It is intentional that the global read observers are not merged with mutable @@ -1505,12 +1551,12 @@ internal class GlobalSnapshot(id: Int, invalid: SnapshotIdSet) : /** A nested mutable snapshot created by [MutableSnapshot.takeNestedMutableSnapshot]. */ internal class NestedMutableSnapshot( - id: Int, + snapshotId: SnapshotId, invalid: SnapshotIdSet, readObserver: ((Any) -> Unit)?, writeObserver: ((Any) -> Unit)?, val parent: MutableSnapshot -) : MutableSnapshot(id, invalid, readObserver, writeObserver) { +) : MutableSnapshot(snapshotId, invalid, readObserver, writeObserver) { private var deactivated = false init { @@ -1538,7 +1584,7 @@ internal class NestedMutableSnapshot( // here making this code a bit simpler than MutableSnapshot.apply. val modified = modified - val id = id + val id = snapshotId val optimisticMerges = if (modified != null) optimisticMerges(parent, this, parent.invalid) else null sync { @@ -1546,7 +1592,8 @@ internal class NestedMutableSnapshot( if (modified == null || modified.size == 0) { closeAndReleasePinning() } else { - val result = innerApplyLocked(parent.id, modified, optimisticMerges, parent.invalid) + val result = + innerApplyLocked(parent.snapshotId, modified, optimisticMerges, parent.invalid) if (result != SnapshotApplyResult.Success) return result parent.modified?.apply { addAll(modified) } @@ -1558,7 +1605,7 @@ internal class NestedMutableSnapshot( } // Ensure the parent is newer than the current snapshot - if (parent.id < id) { + if (parent.snapshotId < id) { parent.advance() } @@ -1623,8 +1670,8 @@ internal class TransparentObserverMutableSnapshot( } } - override var id: Int - get() = currentSnapshot.id + override var snapshotId: SnapshotId + get() = currentSnapshot.snapshotId @Suppress("UNUSED_PARAMETER") set(value) { unsupported() @@ -1732,8 +1779,8 @@ internal class TransparentObserverSnapshot( } } - override var id: Int - get() = currentSnapshot.id + override var snapshotId: SnapshotId + get() = currentSnapshot.snapshotId @Suppress("UNUSED_PARAMETER") set(value) { unsupported() @@ -1832,7 +1879,7 @@ private fun mergedWriteObserver( * snapshots instead of being born invalid. Using `0` ensures all state records are created invalid * and must be explicitly marked as valid in to be visible in a snapshot. */ -private const val INVALID_SNAPSHOT = 0 +private val INVALID_SNAPSHOT = SnapshotIdZero /** Current thread snapshot */ private val threadSnapshot = SnapshotThreadLocal() @@ -1841,7 +1888,7 @@ private val threadSnapshot = SnapshotThreadLocal() * A global synchronization object. This synchronization object should be taken before modifying any * of the fields below. */ -@PublishedApi internal val lock = SynchronizedObject() +@PublishedApi internal val lock = makeSynchronizedObject() @PublishedApi internal inline fun sync(block: () -> T): T = synchronized(lock, block) @@ -1853,7 +1900,7 @@ private val threadSnapshot = SnapshotThreadLocal() private var openSnapshots = SnapshotIdSet.EMPTY /** The first snapshot created must be at least on more than the [Snapshot.PreexistingSnapshotId] */ -private var nextSnapshotId = Snapshot.PreexistingSnapshotId + 1 +private var nextSnapshotId = Snapshot.PreexistingSnapshotId.toSnapshotId() + 1 /** * A tracking table for pinned snapshots. A pinned snapshot is the lowest snapshot id that the @@ -1877,9 +1924,11 @@ private var globalWriteObservers = emptyList<(Any) -> Unit>() private val currentGlobalSnapshot = AtomicReference( - GlobalSnapshot(id = nextSnapshotId++, invalid = SnapshotIdSet.EMPTY).also { - openSnapshots = openSnapshots.set(it.id) - } + GlobalSnapshot( + snapshotId = nextSnapshotId.also { nextSnapshotId += 1 }, + invalid = SnapshotIdSet.EMPTY + ) + .also { openSnapshots = openSnapshots.set(it.snapshotId) } ) /** @@ -1898,12 +1947,13 @@ private fun takeNewGlobalSnapshot( ): T { // Deactivate global snapshot. It is safe to just deactivate it because it cannot have // any conflicting writes as it is always closed before another snapshot is taken. - val result = block(openSnapshots.clear(previousGlobalSnapshot.id)) + val result = block(openSnapshots.clear(previousGlobalSnapshot.snapshotId)) sync { - val globalId = nextSnapshotId++ - openSnapshots = openSnapshots.clear(previousGlobalSnapshot.id) - currentGlobalSnapshot.set(GlobalSnapshot(id = globalId, invalid = openSnapshots)) + val globalId = nextSnapshotId + nextSnapshotId += 1 + openSnapshots = openSnapshots.clear(previousGlobalSnapshot.snapshotId) + currentGlobalSnapshot.set(GlobalSnapshot(snapshotId = globalId, invalid = openSnapshots)) previousGlobalSnapshot.dispose() openSnapshots = openSnapshots.set(globalId) } @@ -1954,22 +2004,22 @@ private fun advanceGlobalSnapshot() = advanceGlobalSnapshot {} private fun takeNewSnapshot(block: (invalid: SnapshotIdSet) -> T): T = advanceGlobalSnapshot { invalid -> val result = block(invalid) - sync { openSnapshots = openSnapshots.set(result.id) } + sync { openSnapshots = openSnapshots.set(result.snapshotId) } result } private fun validateOpen(snapshot: Snapshot) { val openSnapshots = openSnapshots - if (!openSnapshots.get(snapshot.id)) { + if (!openSnapshots.get(snapshot.snapshotId)) { error( - "Snapshot is not open: id=${ - snapshot.id + "Snapshot is not open: snapshotId=${ + snapshot.snapshotId }, disposed=${ snapshot.disposed }, applied=${ (snapshot as? MutableSnapshot)?.applied ?: "read-only" }, lowestPin=${ - sync { pinningTable.lowestOrDefault(-1) } + sync { pinningTable.lowestOrDefault(SnapshotIdInvalidValue) } }" ) } @@ -1987,18 +2037,22 @@ private fun validateOpen(snapshot: Snapshot) { * * INVALID_SNAPSHOT is reserved as an invalid snapshot id. */ -private fun valid(currentSnapshot: Int, candidateSnapshot: Int, invalid: SnapshotIdSet): Boolean { +private fun valid( + currentSnapshot: SnapshotId, + candidateSnapshot: SnapshotId, + invalid: SnapshotIdSet +): Boolean { return candidateSnapshot != INVALID_SNAPSHOT && candidateSnapshot <= currentSnapshot && !invalid.get(candidateSnapshot) } // Determine if the given data is valid for the snapshot. -private fun valid(data: StateRecord, snapshot: Int, invalid: SnapshotIdSet): Boolean { +private fun valid(data: StateRecord, snapshot: SnapshotId, invalid: SnapshotIdSet): Boolean { return valid(snapshot, data.snapshotId, invalid) } -private fun readable(r: T, id: Int, invalid: SnapshotIdSet): T? { +private fun readable(r: T, id: SnapshotId, invalid: SnapshotIdSet): T? { // The readable record is the valid record with the highest snapshotId var current: StateRecord? = r var candidate: StateRecord? = null @@ -2023,7 +2077,7 @@ private fun readable(r: T, id: Int, invalid: SnapshotIdSet): T fun T.readable(state: StateObject): T { val snapshot = Snapshot.current snapshot.readObserver?.invoke(state) - return readable(this, snapshot.id, snapshot.invalid) + return readable(this, snapshot.snapshotId, snapshot.invalid) ?: sync { // Readable can return null when the global snapshot has been advanced by another thread // and state written to the object was overwritten while this thread was paused. @@ -2035,7 +2089,7 @@ fun T.readable(state: StateObject): T { // to this state object until the read completes. val syncSnapshot = Snapshot.current @Suppress("UNCHECKED_CAST") - readable(state.firstStateRecord as T, syncSnapshot.id, syncSnapshot.invalid) + readable(state.firstStateRecord as T, syncSnapshot.snapshotId, syncSnapshot.invalid) ?: readError() } } @@ -2047,7 +2101,7 @@ fun T.readable(state: StateObject): T { fun T.readable(state: StateObject, snapshot: Snapshot): T { // invoke the observer associated with the current snapshot. snapshot.readObserver?.invoke(state) - return readable(this, snapshot.id, snapshot.invalid) ?: readError() + return readable(this, snapshot.snapshotId, snapshot.invalid) ?: readError() } private fun readError(): Nothing { @@ -2182,11 +2236,11 @@ internal fun T.writableRecord(state: StateObject, snapshot: Sn // If the snapshot is read-only, use the snapshot recordModified to report it. snapshot.recordModified(state) } - val id = snapshot.id + val id = snapshot.snapshotId val readData = readable(this, id, snapshot.invalid) ?: readError() // If the readable data was born in this snapshot, it is writable. - if (readData.snapshotId == snapshot.id) return readData + if (readData.snapshotId == snapshot.snapshotId) return readData // Otherwise, make a copy of the readable data and mark it as born in this snapshot, making it // writable. @@ -2200,7 +2254,9 @@ internal fun T.writableRecord(state: StateObject, snapshot: Sn } as T - if (readData.snapshotId != Snapshot.PreexistingSnapshotId) snapshot.recordModified(state) + if (readData.snapshotId != Snapshot.PreexistingSnapshotId.toSnapshotId()) { + snapshot.recordModified(state) + } return newData } @@ -2214,14 +2270,16 @@ internal fun T.overwritableRecord( // If the snapshot is read-only, use the snapshot recordModified to report it. snapshot.recordModified(state) } - val id = snapshot.id + val id = snapshot.snapshotId if (candidate.snapshotId == id) return candidate val newData = sync { newOverwritableRecordLocked(state) } newData.snapshotId = id - if (candidate.snapshotId != Snapshot.PreexistingSnapshotId) snapshot.recordModified(state) + if (candidate.snapshotId != Snapshot.PreexistingSnapshotId.toSnapshotId()) { + snapshot.recordModified(state) + } return newData } @@ -2244,7 +2302,7 @@ private fun T.newWritableRecordLocked(state: StateObject, snap // result of readable(). val newData = newOverwritableRecordLocked(state) newData.assign(this) - newData.snapshotId = snapshot.id + newData.snapshotId = snapshot.snapshotId return newData } @@ -2261,9 +2319,8 @@ internal fun T.newOverwritableRecordLocked(state: StateObject) // cache the result of readable() as the mutating thread calls to writable() can change the // result of readable(). @Suppress("UNCHECKED_CAST") - return (usedLocked(state) as T?)?.apply { snapshotId = Int.MAX_VALUE } - ?: create().apply { - snapshotId = Int.MAX_VALUE + return (usedLocked(state) as T?)?.apply { snapshotId = SnapshotIdMax } + ?: create(SnapshotIdMax).apply { this.next = state.firstStateRecord state.prependStateRecord(this as T) } as T @@ -2351,9 +2408,10 @@ private fun optimisticMerges( invalidSnapshots: SnapshotIdSet ): Map? { val modified = applyingSnapshot.modified - val id = currentSnapshot.id + val id = currentSnapshot.snapshotId if (modified == null) return null - val start = applyingSnapshot.invalid.set(applyingSnapshot.id).or(applyingSnapshot.previousIds) + val start = + applyingSnapshot.invalid.set(applyingSnapshot.snapshotId).or(applyingSnapshot.previousIds) var result: MutableMap? = null modified.forEach { state -> val first = state.firstStateRecord @@ -2362,7 +2420,8 @@ private fun optimisticMerges( if (current != previous) { // Try to produce a merged state record val applied = - readable(first, applyingSnapshot.id, applyingSnapshot.invalid) ?: readError() + readable(first, applyingSnapshot.snapshotId, applyingSnapshot.invalid) + ?: readError() val merged = state.mergeRecords(previous, current, applied) if (merged != null) { (result ?: hashMapOf().also { result = it })[current] = @@ -2387,15 +2446,15 @@ private fun reportReadonlySnapshotWrite(): Nothing { /** Returns the current record without notifying any read observers. */ @PublishedApi internal fun current(r: T, snapshot: Snapshot) = - readable(r, snapshot.id, snapshot.invalid) ?: readError() + readable(r, snapshot.snapshotId, snapshot.invalid) ?: readError() @PublishedApi internal fun current(r: T) = Snapshot.current.let { snapshot -> - readable(r, snapshot.id, snapshot.invalid) + readable(r, snapshot.snapshotId, snapshot.invalid) ?: sync { Snapshot.current.let { syncSnapshot -> - readable(r, syncSnapshot.id, syncSnapshot.invalid) + readable(r, syncSnapshot.snapshotId, syncSnapshot.invalid) } } ?: readError() @@ -2409,8 +2468,12 @@ internal fun current(r: T) = inline fun T.withCurrent(block: (r: T) -> R): R = block(current(this)) /** Helper routine to add a range of values ot a snapshot set */ -internal fun SnapshotIdSet.addRange(from: Int, until: Int): SnapshotIdSet { +internal fun SnapshotIdSet.addRange(from: SnapshotId, until: SnapshotId): SnapshotIdSet { var result = this - for (invalidId in from until until) result = result.set(invalidId) + var invalidId = from + while (invalidId < until) { + result = result.set(invalidId) + invalidId += 1 + } return result } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeap.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeap.kt index 361d22fd642c6..78fb8d26d06ba 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeap.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeap.kt @@ -39,7 +39,7 @@ internal class SnapshotDoubleIndexHeap { private set // An array of values which are the snapshot ids - private var values = IntArray(INITIAL_CAPACITY) + private var values = snapshotIdArrayWithCapacity(INITIAL_CAPACITY) // An array of where the value's handle is in the handles array. private var index = IntArray(INITIAL_CAPACITY) @@ -53,13 +53,13 @@ internal class SnapshotDoubleIndexHeap { // The first free handle. private var firstFreeHandle = 0 - fun lowestOrDefault(default: Int = 0) = if (size > 0) values[0] else default + fun lowestOrDefault(default: SnapshotId = SnapshotIdZero) = if (size > 0) values[0] else default /** * Add a value to the heap by adding it to the end of the heap and then shifting it up until it * is either at the root or its parent is less or equal to it. */ - fun add(value: Int): Int { + fun add(value: SnapshotId): Int { ensure(size + 1) val i = size++ val handle = allocateHandle() @@ -95,7 +95,7 @@ internal class SnapshotDoubleIndexHeap { /** Validate that the handle refers to the expected value. */ @TestOnly - fun validateHandle(handle: Int, value: Int) { + fun validateHandle(handle: Int, value: SnapshotId) { val i = handles[handle] if (index[i] != handle) error("Index for handle $handle is corrupted") if (values[i] != value) @@ -150,14 +150,15 @@ internal class SnapshotDoubleIndexHeap { val values = values val index = index val handles = handles - var t = values[a] + val t = values[a] values[a] = values[b] values[b] = t - t = index[a] - index[a] = index[b] - index[b] = t - handles[index[a]] = a - handles[index[b]] = b + val ia = index[a] + val ib = index[b] + index[a] = ib + index[b] = ia + handles[ib] = a + handles[ia] = b } /** Ensure that the heap can contain at least [atLeast] elements. */ @@ -165,7 +166,7 @@ internal class SnapshotDoubleIndexHeap { val capacity = values.size if (atLeast <= capacity) return val newCapacity = capacity * 2 - val newValues = IntArray(newCapacity) + val newValues = snapshotIdArrayWithCapacity(newCapacity) val newIndex = IntArray(newCapacity) values.copyInto(newValues) index.copyInto(newIndex) diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt new file mode 100644 index 0000000000000..2d2c9f6b6d645 --- /dev/null +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt @@ -0,0 +1,75 @@ +/* + * 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. + */ + +@file:Suppress("EXTENSION_SHADOWED_BY_MEMBER") + +package androidx.compose.runtime.snapshots + +expect class SnapshotId + +expect val SnapshotIdZero: SnapshotId +expect val SnapshotIdMax: SnapshotId +expect val SnapshotIdInvalidValue: SnapshotId + +expect val SnapshotIdSize: Int + +expect operator fun SnapshotId.compareTo(other: SnapshotId): Int + +expect operator fun SnapshotId.compareTo(other: Int): Int + +expect operator fun SnapshotId.plus(other: Int): SnapshotId + +expect operator fun SnapshotId.minus(other: SnapshotId): SnapshotId + +expect operator fun SnapshotId.minus(other: Int): SnapshotId + +expect operator fun SnapshotId.div(other: Int): SnapshotId + +expect operator fun SnapshotId.times(other: Int): SnapshotId + +expect fun SnapshotId.toInt(): Int + +expect class SnapshotIdArray + +internal expect fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray + +internal expect fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray + +internal expect operator fun SnapshotIdArray.get(index: Int): SnapshotId + +internal expect operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) + +internal expect val SnapshotIdArray.size: Int + +internal expect fun SnapshotIdArray.copyInto(other: SnapshotIdArray) + +internal expect fun SnapshotIdArray.first(): SnapshotId + +internal expect inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) + +internal expect fun SnapshotIdArray.binarySearch(id: SnapshotId): Int + +internal expect fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray + +internal expect fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? + +internal expect class SnapshotIdArrayBuilder(array: SnapshotIdArray?) { + fun add(id: SnapshotId) + + fun toArray(): SnapshotIdArray? +} + +internal expect fun Int.toSnapshotId(): SnapshotId diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSet.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSet.kt index 28f7998652862..74ce7bbcf9f3b 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSet.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSet.kt @@ -45,28 +45,28 @@ private constructor( private val lowerSet: Long, // Lower bound of the bit set. All values above lowerBound+127 are clear. // Values between lowerBound and lowerBound+127 are recorded in lowerSet and upperSet - private val lowerBound: Int, + private val lowerBound: SnapshotId, // A sorted array of the index of bits set below lowerBound - private val belowBound: IntArray? -) : Iterable { + private val belowBound: SnapshotIdArray? +) : Iterable { - /** The value of the bit at index [bit] */ - fun get(bit: Int): Boolean { - val offset = bit - lowerBound - if (offset >= 0 && offset < Long.SIZE_BITS) { - return (1L shl offset) and lowerSet != 0L + /** The value of the bit at index [id] */ + fun get(id: SnapshotId): Boolean { + val offset = id - lowerBound + return if (offset >= 0 && offset < Long.SIZE_BITS) { + (1L shl offset.toInt()) and lowerSet != 0L } else if (offset >= Long.SIZE_BITS && offset < Long.SIZE_BITS * 2) { - return (1L shl (offset - Long.SIZE_BITS)) and upperSet != 0L + (1L shl (offset.toInt() - Long.SIZE_BITS)) and upperSet != 0L } else if (offset > 0) { - return false - } else return belowBound?.let { it.binarySearch(bit) >= 0 } ?: false + false + } else belowBound?.let { it.binarySearch(id) >= 0 } ?: false } - /** Produce a copy of this set with the addition of the bit at index [bit] set. */ - fun set(bit: Int): SnapshotIdSet { - val offset = bit - lowerBound + /** Produce a copy of this set with the addition of the bit at index [id] set. */ + fun set(id: SnapshotId): SnapshotIdSet { + val offset = id - lowerBound if (offset >= 0 && offset < Long.SIZE_BITS) { - val mask = 1L shl offset + val mask = 1L shl offset.toInt() if (lowerSet and mask == 0L) { return SnapshotIdSet( upperSet = upperSet, @@ -76,7 +76,7 @@ private constructor( ) } } else if (offset >= Long.SIZE_BITS && offset < Long.SIZE_BITS * 2) { - val mask = 1L shl (offset - Long.SIZE_BITS) + val mask = 1L shl (offset.toInt() - Long.SIZE_BITS) if (upperSet and mask == 0L) { return SnapshotIdSet( upperSet = upperSet or mask, @@ -86,24 +86,24 @@ private constructor( ) } } else if (offset >= Long.SIZE_BITS * 2) { - if (!get(bit)) { + if (!get(id)) { // Shift the bit array down var newUpperSet = upperSet var newLowerSet = lowerSet var newLowerBound = lowerBound - var newBelowBound: MutableList? = null - val targetLowerBound = (bit + 1) / Long.SIZE_BITS * Long.SIZE_BITS + var newBelowBound: SnapshotIdArrayBuilder? = null + val targetLowerBound = + (((id + 1) / SnapshotIdSize) * SnapshotIdSize).let { + if (it < 0) SnapshotIdMax - (SnapshotIdSize * 2) + 1 else it + } while (newLowerBound < targetLowerBound) { // Shift the lower set into the array if (newLowerSet != 0L) { if (newBelowBound == null) - newBelowBound = - mutableListOf().apply { - belowBound?.let { it.forEach { this.add(it) } } - } + newBelowBound = SnapshotIdArrayBuilder(belowBound) repeat(Long.SIZE_BITS) { bitOffset -> if (newLowerSet and (1L shl bitOffset) != 0L) { - newBelowBound.add(bitOffset + newLowerBound) + newBelowBound.add(newLowerBound + bitOffset) } } } @@ -121,32 +121,19 @@ private constructor( newUpperSet, newLowerSet, newLowerBound, - newBelowBound?.toIntArray() ?: belowBound + newBelowBound?.toArray() ?: belowBound ) - .set(bit) + .set(id) } } else { val array = - belowBound ?: return SnapshotIdSet(upperSet, lowerSet, lowerBound, intArrayOf(bit)) + belowBound + ?: return SnapshotIdSet(upperSet, lowerSet, lowerBound, snapshotIdArrayOf(id)) - val location = array.binarySearch(bit) + val location = array.binarySearch(id) if (location < 0) { val insertLocation = -(location + 1) - val newSize = array.size + 1 - val newBelowBound = IntArray(newSize) - array.copyInto( - destination = newBelowBound, - destinationOffset = 0, - startIndex = 0, - endIndex = insertLocation - ) - array.copyInto( - destination = newBelowBound, - destinationOffset = insertLocation + 1, - startIndex = insertLocation, - endIndex = newSize - 1 - ) - newBelowBound[insertLocation] = bit + val newBelowBound = array.withIdInsertedAt(insertLocation, id) return SnapshotIdSet(upperSet, lowerSet, lowerBound, newBelowBound) } } @@ -155,11 +142,11 @@ private constructor( return this } - /** Produce a copy of this set with the addition of the bit at index [bit] cleared. */ - fun clear(bit: Int): SnapshotIdSet { - val offset = bit - lowerBound + /** Produce a copy of this set with the addition of the bit at index [id] cleared. */ + fun clear(id: SnapshotId): SnapshotIdSet { + val offset = id - lowerBound if (offset >= 0 && offset < Long.SIZE_BITS) { - val mask = 1L shl offset + val mask = 1L shl offset.toInt() if (lowerSet and mask != 0L) { return SnapshotIdSet( upperSet = upperSet, @@ -169,7 +156,7 @@ private constructor( ) } } else if (offset >= Long.SIZE_BITS && offset < Long.SIZE_BITS * 2) { - val mask = 1L shl (offset - Long.SIZE_BITS) + val mask = 1L shl (offset.toInt() - Long.SIZE_BITS) if (upperSet and mask != 0L) { return SnapshotIdSet( upperSet = upperSet and mask.inv(), @@ -181,30 +168,14 @@ private constructor( } else if (offset < 0) { val array = belowBound if (array != null) { - val location = array.binarySearch(bit) + val location = array.binarySearch(id) if (location >= 0) { - val newSize = array.size - 1 - if (newSize == 0) { - return SnapshotIdSet(upperSet, lowerSet, lowerBound, null) - } - val newBelowBound = IntArray(newSize) - if (location > 0) { - array.copyInto( - destination = newBelowBound, - destinationOffset = 0, - startIndex = 0, - endIndex = location - ) - } - if (location < newSize) { - array.copyInto( - destination = newBelowBound, - destinationOffset = location, - startIndex = location + 1, - endIndex = newSize + 1 - ) - } - return SnapshotIdSet(upperSet, lowerSet, lowerBound, newBelowBound) + return SnapshotIdSet( + upperSet, + lowerSet, + lowerBound, + array.withIdRemovedAt(location) + ) } } } @@ -212,43 +183,43 @@ private constructor( return this } - /** Produce a copy of this with all the values in [bits] cleared (`a & ~b`) */ - fun andNot(bits: SnapshotIdSet): SnapshotIdSet { - if (bits === EMPTY) return this + /** Produce a copy of this with all the values in [ids] cleared (`a & ~b`) */ + fun andNot(ids: SnapshotIdSet): SnapshotIdSet { + if (ids === EMPTY) return this if (this === EMPTY) return EMPTY - return if (bits.lowerBound == this.lowerBound && bits.belowBound === this.belowBound) { + return if (ids.lowerBound == this.lowerBound && ids.belowBound === this.belowBound) { SnapshotIdSet( - this.upperSet and bits.upperSet.inv(), - this.lowerSet and bits.lowerSet.inv(), + this.upperSet and ids.upperSet.inv(), + this.lowerSet and ids.lowerSet.inv(), this.lowerBound, this.belowBound ) } else { - bits.fastFold(this) { previous, index -> previous.clear(index) } + ids.fastFold(this) { previous, index -> previous.clear(index) } } } - fun and(bits: SnapshotIdSet): SnapshotIdSet { - if (bits == EMPTY) return EMPTY + fun and(ids: SnapshotIdSet): SnapshotIdSet { + if (ids == EMPTY) return EMPTY if (this == EMPTY) return EMPTY - return if (bits.lowerBound == this.lowerBound && bits.belowBound === this.belowBound) { - val newUpper = this.upperSet and bits.upperSet - val newLower = this.lowerSet and bits.lowerSet + return if (ids.lowerBound == this.lowerBound && ids.belowBound === this.belowBound) { + val newUpper = this.upperSet and ids.upperSet + val newLower = this.lowerSet and ids.lowerSet if (newUpper == 0L && newLower == 0L && this.belowBound == null) EMPTY else SnapshotIdSet( - this.upperSet and bits.upperSet, - this.lowerSet and bits.lowerSet, + this.upperSet and ids.upperSet, + this.lowerSet and ids.lowerSet, this.lowerBound, this.belowBound ) } else { if (this.belowBound == null) this.fastFold(EMPTY) { previous, index -> - if (bits.get(index)) previous.set(index) else previous + if (ids.get(index)) previous.set(index) else previous } else - bits.fastFold(EMPTY) { previous, index -> + ids.fastFold(EMPTY) { previous, index -> if (this.get(index)) previous.set(index) else previous } } @@ -276,64 +247,56 @@ private constructor( } } - override fun iterator(): Iterator = + override fun iterator(): Iterator = sequence { - val belowBound = belowBound - if (belowBound != null) - for (element in belowBound) { - yield(element) - } + this@SnapshotIdSet.belowBound?.forEach { yield(it) } if (lowerSet != 0L) { for (index in 0 until Long.SIZE_BITS) { if (lowerSet and (1L shl index) != 0L) { - yield(index + lowerBound) + yield(lowerBound + index) } } } if (upperSet != 0L) { for (index in 0 until Long.SIZE_BITS) { if (upperSet and (1L shl index) != 0L) { - yield(index + Long.SIZE_BITS + lowerBound) + yield(lowerBound + index + Long.SIZE_BITS) } } } } .iterator() - inline fun fastFold( + private inline fun fastFold( initial: SnapshotIdSet, - operation: (acc: SnapshotIdSet, Int) -> SnapshotIdSet + operation: (acc: SnapshotIdSet, SnapshotId) -> SnapshotIdSet ): SnapshotIdSet { var accumulator = initial fastForEach { element -> accumulator = operation(accumulator, element) } return accumulator } - inline fun fastForEach(block: (Int) -> Unit) { - val belowBound = belowBound - if (belowBound != null) - for (element in belowBound) { - block(element) - } + inline fun fastForEach(block: (SnapshotId) -> Unit) { + this.belowBound?.forEach(block) if (lowerSet != 0L) { for (index in 0 until Long.SIZE_BITS) { if (lowerSet and (1L shl index) != 0L) { - block(index + lowerBound) + block(lowerBound + index) } } } if (upperSet != 0L) { for (index in 0 until Long.SIZE_BITS) { if (upperSet and (1L shl index) != 0L) { - block(index + Long.SIZE_BITS + lowerBound) + block(lowerBound + index + Long.SIZE_BITS) } } } } - fun lowest(default: Int): Int { + fun lowest(default: SnapshotId): SnapshotId { val belowBound = belowBound - if (belowBound != null) return belowBound[0] + if (belowBound != null) return belowBound.first() if (lowerSet != 0L) return lowerBound + lowerSet.countTrailingZeroBits() if (upperSet != 0L) return lowerBound + Long.SIZE_BITS + upperSet.countTrailingZeroBits() return default @@ -346,18 +309,6 @@ private constructor( companion object { /** An empty frame it set */ - val EMPTY = SnapshotIdSet(0, 0, 0, null) - } -} - -internal fun IntArray.binarySearch(value: Int): Int { - var low = 0 - var high = size - 1 - - while (low <= high) { - val mid = (low + high).ushr(1) - val midVal = get(mid) - if (value > midVal) low = mid + 1 else if (value < midVal) high = mid - 1 else return mid + val EMPTY = SnapshotIdSet(0, 0, SnapshotIdZero, null) } - return -(low + 1) } diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateList.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateList.kt index 2760b72d939bf..6c217cc5018d6 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateList.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateList.kt @@ -17,11 +17,11 @@ package androidx.compose.runtime.snapshots import androidx.compose.runtime.Stable -import androidx.compose.runtime.SynchronizedObject import androidx.compose.runtime.external.kotlinx.collections.immutable.PersistentList import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentListOf +import androidx.compose.runtime.platform.makeSynchronizedObject import androidx.compose.runtime.requirePrecondition -import androidx.compose.runtime.synchronized +import androidx.compose.runtime.platform.synchronized import kotlin.jvm.JvmName /** @@ -72,7 +72,8 @@ class SnapshotStateList internal constructor(persistentList: PersistentList - internal constructor(internal var list: PersistentList) : StateRecord() { + internal constructor(snapshotId: SnapshotId, internal var list: PersistentList) : + StateRecord(snapshotId) { internal var modification = 0 internal var structuralChange = 0 @@ -85,7 +86,10 @@ class SnapshotStateList internal constructor(persistentList: PersistentList internal constructor(persistentList: PersistentList internal constructor(persistentList: PersistentList internal constructor(persistentList: PersistentList.attemptUpdate( + currentModification: Int, + newList: PersistentList, + structural: Boolean + ): Boolean = + synchronized(sync) { + if (modification == currentModification) { + list = newList + if (structural) structuralChange++ + modification++ + true + } else false + } + private fun stateRecordWith(list: PersistentList): StateRecord { - return StateListStateRecord(list).also { - if (Snapshot.isInSnapshot) { - it.next = - StateListStateRecord(list).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + val snapshot = currentSnapshot() + return StateListStateRecord(snapshot.snapshotId, list).also { + if (snapshot !is GlobalSnapshot) { + it.next = StateListStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), list) } } } @@ -296,9 +293,10 @@ fun SnapshotStateList(size: Int, init: (index: Int) -> T): SnapshotStateList * additional contention introduced by this lock is nominal. * * In code the requires this lock and calls `writable` (or other operation that acquires the - * snapshot global lock), this lock *MUST* be acquired first to avoid deadlocks. + * snapshot global lock), this lock *MUST* be acquired last to avoid deadlocks. In other words, the + * lock must be taken in the `writable` lambda, if `writable` is used. */ -private val sync = SynchronizedObject() +private val sync = makeSynchronizedObject() private fun modificationError(): Nothing = error("Cannot modify a state list through an iterator") diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMap.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMap.kt index ac20fb6c5e1e2..1b97bed46740c 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMap.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMap.kt @@ -17,10 +17,10 @@ package androidx.compose.runtime.snapshots import androidx.compose.runtime.Stable -import androidx.compose.runtime.SynchronizedObject import androidx.compose.runtime.external.kotlinx.collections.immutable.PersistentMap import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentHashMapOf -import androidx.compose.runtime.synchronized +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import kotlin.jvm.JvmName /** @@ -35,12 +35,11 @@ import kotlin.jvm.JvmName class SnapshotStateMap : StateObject, MutableMap { override var firstStateRecord: StateRecord = persistentHashMapOf().let { map -> - StateMapStateRecord(map).also { - if (Snapshot.isInSnapshot) { + val snapshot = currentSnapshot() + StateMapStateRecord(snapshot.snapshotId, map).also { + if (snapshot !is GlobalSnapshot) { it.next = - StateMapStateRecord(map).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + StateMapStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), map) } } } @@ -167,37 +166,39 @@ class SnapshotStateMap : StateObject, MutableMap { val builder = oldMap!!.builder() result = block(builder) val newMap = builder.build() - if ( - newMap == oldMap || - writable { - synchronized(sync) { - if (modification == currentModification) { - map = newMap - modification++ - true - } else false - } - } - ) - break + if (newMap == oldMap || writable { attemptUpdate(currentModification, newMap) }) break } return result } + private fun StateMapStateRecord.attemptUpdate( + currentModification: Int, + newMap: PersistentMap + ) = + synchronized(sync) { + if (modification == currentModification) { + map = newMap + modification++ + true + } else false + } + private inline fun update(block: (PersistentMap) -> PersistentMap) = withCurrent { val newMap = block(map) - if (newMap !== map) - writable { - synchronized(sync) { - map = newMap - modification++ - } - } + if (newMap !== map) writable { commitUpdate(newMap) } } + // NOTE: do not inline this method to avoid class verification failures, see b/369909868 + private fun StateMapStateRecord.commitUpdate(newMap: PersistentMap) = + synchronized(sync) { + map = newMap + modification++ + } + /** Implementation class of [SnapshotStateMap]. Do not use. */ internal class StateMapStateRecord - internal constructor(internal var map: PersistentMap) : StateRecord() { + internal constructor(snapshotId: SnapshotId, internal var map: PersistentMap) : + StateRecord(snapshotId) { internal var modification = 0 override fun assign(value: StateRecord) { @@ -208,7 +209,10 @@ class SnapshotStateMap : StateObject, MutableMap { } } - override fun create(): StateRecord = StateMapStateRecord(map) + override fun create(): StateRecord = StateMapStateRecord(currentSnapshot().snapshotId, map) + + override fun create(snapshotId: SnapshotId): StateRecord = + StateMapStateRecord(snapshotId, map) } } @@ -317,9 +321,10 @@ private class SnapshotMapValueSet(map: SnapshotStateMap) : * additional contention introduced by this lock is nominal. * * In code the requires this lock and calls `writable` (or other operation that acquires the - * snapshot global lock), this lock *MUST* be acquired first to avoid deadlocks. + * snapshot global lock), this lock *MUST* be acquired last to avoid deadlocks. In other words, the + * lock must be taken in the `writable` lambda, if `writable` is used. */ -private val sync = SynchronizedObject() +private val sync = makeSynchronizedObject() private abstract class StateMapMutableIterator( val map: SnapshotStateMap, diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt index 33f98c1a79ce0..7c127dd2f042a 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt @@ -21,7 +21,6 @@ import androidx.collection.MutableScatterMap import androidx.collection.MutableScatterSet import androidx.compose.runtime.DerivedState import androidx.compose.runtime.DerivedStateObserver -import androidx.compose.runtime.SynchronizedObject import androidx.compose.runtime.TestOnly import androidx.compose.runtime.collection.ScopeMap import androidx.compose.runtime.collection.fastForEach @@ -31,9 +30,10 @@ import androidx.compose.runtime.internal.AtomicReference import androidx.compose.runtime.internal.currentThreadId import androidx.compose.runtime.internal.currentThreadName import androidx.compose.runtime.observeDerivedStateRecalculations +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized import androidx.compose.runtime.requirePrecondition import androidx.compose.runtime.structuralEqualityPolicy -import androidx.compose.runtime.synchronized /** * Helper class to efficiently observe snapshot state reads. See [observeReads] for more details. @@ -172,7 +172,7 @@ class SnapshotStateObserver(private val onChangedExecutor: (callback: () -> Unit * The list only grows. */ private val observedScopeMaps = mutableVectorOf() - private val observedScopeMapsLock = SynchronizedObject() + private val observedScopeMapsLock = makeSynchronizedObject() /** * Helper for synchronized iteration over [observedScopeMaps]. All observed reads should happen @@ -453,7 +453,7 @@ class SnapshotStateObserver(private val onChangedExecutor: (callback: () -> Unit currentScope = scope currentScopeReads = scopeToValues[scope] if (currentToken == -1) { - currentToken = currentSnapshot().id + currentToken = currentSnapshot().snapshotId.hashCode() } observeDerivedStateRecalculations(derivedStateObserver) { @@ -572,7 +572,7 @@ class SnapshotStateObserver(private val onChangedExecutor: (callback: () -> Unit fun rereadDerivedState(derivedState: DerivedState<*>) { val scopeToValues = scopeToValues - val token = currentSnapshot().id + val token = currentSnapshot().snapshotId.hashCode() valueToScopes.forEachScopeOf(derivedState) { scope -> recordRead( diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateSet.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateSet.kt index 7598626b2495a..cf224d9380708 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateSet.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateSet.kt @@ -17,10 +17,11 @@ package androidx.compose.runtime.snapshots import androidx.compose.runtime.Stable -import androidx.compose.runtime.SynchronizedObject import androidx.compose.runtime.external.kotlinx.collections.immutable.PersistentSet import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentSetOf -import androidx.compose.runtime.synchronized +import androidx.compose.runtime.platform.makeSynchronizedObject +import androidx.compose.runtime.platform.synchronized +import kotlin.js.JsName import kotlin.jvm.JvmName /** @@ -65,8 +66,9 @@ class SnapshotStateSet : StateObject, MutableSet, RandomAccess { get() = (firstStateRecord as StateSetStateRecord).readable(this) /** This is an internal implementation class of [SnapshotStateSet]. Do not use. */ - internal class StateSetStateRecord internal constructor(internal var set: PersistentSet) : - StateRecord() { + internal class StateSetStateRecord + internal constructor(snapshotId: SnapshotId, internal var set: PersistentSet) : + StateRecord(snapshotId) { internal var modification = 0 override fun assign(value: StateRecord) { @@ -77,7 +79,10 @@ class SnapshotStateSet : StateObject, MutableSet, RandomAccess { } } - override fun create(): StateRecord = StateSetStateRecord(set) + override fun create(): StateRecord = StateSetStateRecord(currentSnapshot().snapshotId, set) + + override fun create(snapshotId: SnapshotId): StateRecord = + StateSetStateRecord(snapshotId, set) } override val size: Int @@ -148,19 +153,7 @@ class SnapshotStateSet : StateObject, MutableSet, RandomAccess { val builder = oldSet?.builder() ?: error("No set to mutate") result = block(builder) val newSet = builder.build() - if ( - newSet == oldSet || - writable { - synchronized(sync) { - if (modification == currentModification) { - set = newSet - modification++ - true - } else false - } - } - ) - break + if (newSet == oldSet || writable { attemptUpdate(currentModification, newSet) }) break } return result } @@ -180,17 +173,7 @@ class SnapshotStateSet : StateObject, MutableSet, RandomAccess { result = false break } - if ( - writable { - synchronized(sync) { - if (modification == currentModification) { - set = newSet - modification++ - true - } else false - } - } - ) { + if (writable { attemptUpdate(currentModification, newSet) }) { result = true break } @@ -198,13 +181,23 @@ class SnapshotStateSet : StateObject, MutableSet, RandomAccess { result } + // NOTE: do not inline this method to avoid class verification failures, see b/369909868 + private fun StateSetStateRecord.attemptUpdate( + currentModification: Int, + newSet: PersistentSet + ): Boolean = + synchronized(sync) { + if (modification == currentModification) { + set = newSet + modification++ + true + } else false + } + private fun stateRecordWith(set: PersistentSet): StateRecord { - return StateSetStateRecord(set).also { + return StateSetStateRecord(currentSnapshot().snapshotId, set).also { if (Snapshot.isInSnapshot) { - it.next = - StateSetStateRecord(set).also { next -> - next.snapshotId = Snapshot.PreexistingSnapshotId - } + it.next = StateSetStateRecord(Snapshot.PreexistingSnapshotId.toSnapshotId(), set) } } } @@ -219,17 +212,16 @@ class SnapshotStateSet : StateObject, MutableSet, RandomAccess { * between sets. As there is already contention on the global snapshot lock to write so the * additional contention introduced by this lock is nominal. * - * In code that requires this lock and calls `writable` (or other operation that acquires the - * snapshot global lock), this lock *MUST* be acquired first to avoid deadlocks. + * In code the requires this lock and calls `writable` (or other operation that acquires the + * snapshot global lock), this lock *MUST* be acquired last to avoid deadlocks. In other words, the + * lock must be taken in the `writable` lambda, if `writable` is used. */ -private val sync = SynchronizedObject() +private val sync = makeSynchronizedObject() private class StateSetIterator(val set: SnapshotStateSet, val iterator: Iterator) : MutableIterator { var current: T? = null - @Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE") - @kotlin.js.JsName("_hasNext") - var next: T? = null + @JsName("var_next") var next: T? = null var modification = set.modification init { diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/CompositionObserver.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/CompositionObserver.kt index 659cc9a07c15e..2387070b297d7 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/CompositionObserver.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/CompositionObserver.kt @@ -21,8 +21,51 @@ import androidx.compose.runtime.CompositionImplServiceKey import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.compose.runtime.RecomposeScope import androidx.compose.runtime.RecomposeScopeImpl +import androidx.compose.runtime.Recomposer import androidx.compose.runtime.getCompositionService +/** + * Observe when new compositions are added to a recomposer. This, combined with, + * [CompositionObserver], allows observing when any composition is being performed. + * + * This observer is registered with a [Recomposer] by calling [Recomposer.observe]. + */ +@ExperimentalComposeRuntimeApi +@Suppress("CallbackName") +interface CompositionRegistrationObserver { + + /** + * Called whenever a [Composition] is registered with a [Recomposer] for which this is an + * observer. A Composition is registered with its Recomposer when it begins its initial + * composition, before any content is added. When a [CompositionRegistrationObserver] is + * registered, this method will be called for all the [Recomposer]'s currently known + * composition. + * + * This method is called on the same thread that the [Composition] being registered is being + * composed on. During the initial dispatch, it is invoked on the same thread that the callback + * is being registered on. Implementations of this method should be thread safe as they might be + * called on an arbitrary thread. + * + * @param recomposer The [Recomposer] the [composition] was registered with. This is always the + * instance of the [Recomposer] that `observe` was called. + * @param composition The [Composition] instance that is being registered with the recomposer. + */ + fun onCompositionRegistered(recomposer: Recomposer, composition: Composition) + + /** + * Called whenever a [Composition] is unregistered with a [Recomposer] for which this is an + * observer. A Composition is unregistered from its Recomposer when the composition is + * [disposed][Composition.dispose]. This method is called on the same thread that the + * [Composition] being unregistered was composed on. Implementations of this method should be + * thread safe as they might be called on an arbitrary thread. + * + * @param recomposer The [Recomposer] the [composition] was registered with. This is always the + * instance of the [Recomposer] that `observe` was called. + * @param composition The [Composition] instance that is being unregistered with the recomposer. + */ + fun onCompositionUnregistered(recomposer: Recomposer, composition: Composition) +} + /** Observe when the composition begins and ends. */ @ExperimentalComposeRuntimeApi @Suppress("CallbackName") @@ -75,6 +118,21 @@ interface CompositionObserverHandle { fun dispose() } +/** + * Register an observer to be notified when a composition is added to or removed from the given + * [Recomposer]. When this method is called, the observer will be notified of all currently + * registered compositions per the documentation in + * [CompositionRegistrationObserver.onCompositionRegistered]. + * + * @param observer the observer that will be informed of new compositions registered with this + * [Recomposer]. + * @return a handle that allows the observer to be disposed and detached from the [Recomposer]. + */ +@ExperimentalComposeRuntimeApi +fun Recomposer.observe(observer: CompositionRegistrationObserver): CompositionObserverHandle { + return addCompositionRegistrationObserver(observer) +} + /** * Observe the composition. Calling this twice on the same composition will implicitly dispose the * previous observer. the [CompositionObserver] will be called for this composition and all diff --git a/compose/runtime/runtime/src/darwinMain/kotlin/runtime/SynchronizedObject.darwin.kt b/compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt similarity index 94% rename from compose/runtime/runtime/src/darwinMain/kotlin/runtime/SynchronizedObject.darwin.kt rename to compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt index 64712df8a57b1..68eab974e23eb 100644 --- a/compose/runtime/runtime/src/darwinMain/kotlin/runtime/SynchronizedObject.darwin.kt +++ b/compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt @@ -14,6 +14,6 @@ * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.platform internal actual val PTHREAD_MUTEX_ERRORCHECK: Int = platform.posix.PTHREAD_MUTEX_ERRORCHECK diff --git a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/IdentityHashCode.jvm.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualJvm.jvm.kt similarity index 67% rename from compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/IdentityHashCode.jvm.kt rename to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualJvm.jvm.kt index f12fea70a94a1..3748ab41d743d 100644 --- a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/IdentityHashCode.jvm.kt +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualJvm.jvm.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 The Android Open Source Project + * 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. @@ -14,13 +14,23 @@ * limitations under the License. */ -@file:JvmName("ActualJvm_jvmKt") -@file:JvmMultifileClass - package androidx.compose.runtime +import kotlin.DeprecationLevel.HIDDEN + // TODO https://youtrack.jetbrains.com/issue/CMP-719/Make-expect-fun-identityHashCodeinstance-Any-Int-internal @InternalComposeApi @Deprecated("Made internal. It wasn't supposed to be public") fun identityHashCode(instance: Any?): Int = - androidx.compose.runtime.internal.identityHashCode(instance) \ No newline at end of file + androidx.compose.runtime.internal.identityHashCode(instance) + +internal class SynchronizedObject + +@PublishedApi +@JvmName("synchronized") +@Deprecated( + level = HIDDEN, + message = "not expected to be referenced directly as the old version had to be inlined" +) +internal inline fun oldSynchronized2(lock: SynchronizedObject, block: () -> R): R = + androidx.compose.runtime.platform.synchronized(lock, block) diff --git a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/Synchronization.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/Synchronization.kt index 20873e2f17a7a..830446e5770f4 100644 --- a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/Synchronization.kt +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/Synchronization.kt @@ -20,5 +20,9 @@ import kotlin.DeprecationLevel.* @PublishedApi @JvmName("synchronized") -@Deprecated(level = HIDDEN, message = "Use SynchronizedObjectKt.synchronized() instead") -internal fun oldSynchronized(lock: SynchronizedObject, block: () -> R): R = synchronized(lock, block) \ No newline at end of file +@Deprecated( + level = HIDDEN, + message = "not expected to be referenced directly as the old version had to be inlined" +) +internal inline fun oldSynchronized(lock: SynchronizedObject, block: () -> R): R = + androidx.compose.runtime.platform.synchronized(lock, block) \ No newline at end of file diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/SynchronizedObject.jvm.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt similarity index 69% rename from compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/SynchronizedObject.jvm.kt rename to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt index bb691113ad63c..af3ada93d6f03 100644 --- a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/SynchronizedObject.jvm.kt +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package androidx.compose.runtime.platform -@file:JvmName("ActualJvm_jvmKt") -@file:JvmMultifileClass +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias SynchronizedObject = androidx.compose.runtime.SynchronizedObject -package androidx.compose.runtime - -internal actual class SynchronizedObject +@Suppress("NOTHING_TO_INLINE") +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() @PublishedApi internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R = diff --git a/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt new file mode 100644 index 0000000000000..997f3caef6770 --- /dev/null +++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt @@ -0,0 +1,143 @@ +/* + * 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. + */ + +@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER") + +package androidx.compose.runtime.snapshots + +import androidx.collection.mutableDoubleListOf + +// TODO https://youtrack.jetbrains.com/issue/CMP-7149/Adopt-non-long-SnapshotId + +actual typealias SnapshotId = Double + +actual const val SnapshotIdZero: SnapshotId = 0.0 +actual const val SnapshotIdMax: SnapshotId = Double.MAX_VALUE +actual const val SnapshotIdSize: Int = Double.SIZE_BITS +actual const val SnapshotIdInvalidValue: SnapshotId = -1.0 + +actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = this.compareTo(other) + +actual inline operator fun SnapshotId.compareTo(other: Int): Int = this.compareTo(other.toLong()) + +actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() + +actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other + +actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() + +actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() + +actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() + +actual inline fun SnapshotId.toInt(): Int = this.toInt() + +actual typealias SnapshotIdArray = DoubleArray + +internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray = + DoubleArray(capacity) + +internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index] + +internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) { + this[index] = value +} + +internal actual inline val SnapshotIdArray.size: Int + get() = this.size + +internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) { + this.copyInto(other, 0) +} + +internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0] + +internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int { + var low = 0 + var high = size - 1 + + while (low <= high) { + val mid = (low + high).ushr(1) + val midVal = get(mid) + if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid + } + return -(low + 1) +} + +internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) { + for (value in this) { + block(value) + } +} + +internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray { + val newSize = size + 1 + val newArray = DoubleArray(newSize) + this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index) + this.copyInto( + destination = newArray, + destinationOffset = index + 1, + startIndex = index, + endIndex = newSize - 1 + ) + newArray[index] = id + return newArray +} + +internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? { + val newSize = this.size - 1 + if (newSize == 0) { + return null + } + val newArray = DoubleArray(newSize) + if (index > 0) { + this.copyInto( + destination = newArray, + destinationOffset = 0, + startIndex = 0, + endIndex = index + ) + } + if (index < newSize) { + this.copyInto( + destination = newArray, + destinationOffset = index, + startIndex = index + 1, + endIndex = newSize + 1 + ) + } + return newArray +} + +internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) { + private val list = array?.let { mutableDoubleListOf(*array) } ?: mutableDoubleListOf() + + actual fun add(id: SnapshotId) { + list.add(id) + } + + actual fun toArray(): SnapshotIdArray? { + val size = list.size + if (size == 0) return null + val result = DoubleArray(size) + list.forEachIndexed { index, element -> result[index] = element } + return result + } +} + +internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = doubleArrayOf(id) + +internal actual fun Int.toSnapshotId(): SnapshotId = toDouble() diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt new file mode 100644 index 0000000000000..4ba29a491ca2e --- /dev/null +++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt @@ -0,0 +1,141 @@ +/* + * 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. + */ + +@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER") + +package androidx.compose.runtime.snapshots + +import androidx.collection.mutableLongListOf + +actual typealias SnapshotId = Long + +actual const val SnapshotIdZero: SnapshotId = 0L +actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE +actual const val SnapshotIdSize: Int = Long.SIZE_BITS +actual const val SnapshotIdInvalidValue: SnapshotId = -1 + +actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = this.compareTo(other) + +actual inline operator fun SnapshotId.compareTo(other: Int): Int = this.compareTo(other.toLong()) + +actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() + +actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other + +actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() + +actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() + +actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() + +actual inline fun SnapshotId.toInt(): Int = this.toInt() + +actual typealias SnapshotIdArray = LongArray + +internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray = + LongArray(capacity) + +internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index] + +internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) { + this[index] = value +} + +internal actual inline val SnapshotIdArray.size: Int + get() = this.size + +internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) { + this.copyInto(other, 0) +} + +internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0] + +internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int { + var low = 0 + var high = size - 1 + + while (low <= high) { + val mid = (low + high).ushr(1) + val midVal = get(mid) + if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid + } + return -(low + 1) +} + +internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) { + for (value in this) { + block(value) + } +} + +internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray { + val newSize = size + 1 + val newArray = LongArray(newSize) + this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index) + this.copyInto( + destination = newArray, + destinationOffset = index + 1, + startIndex = index, + endIndex = newSize - 1 + ) + newArray[index] = id + return newArray +} + +internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? { + val newSize = this.size - 1 + if (newSize == 0) { + return null + } + val newArray = LongArray(newSize) + if (index > 0) { + this.copyInto( + destination = newArray, + destinationOffset = 0, + startIndex = 0, + endIndex = index + ) + } + if (index < newSize) { + this.copyInto( + destination = newArray, + destinationOffset = index, + startIndex = index + 1, + endIndex = newSize + 1 + ) + } + return newArray +} + +internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) { + private val list = array?.let { mutableLongListOf(*array) } ?: mutableLongListOf() + + actual fun add(id: SnapshotId) { + list.add(id) + } + + actual fun toArray(): SnapshotIdArray? { + val size = list.size + if (size == 0) return null + val result = LongArray(size) + list.forEachIndexed { index, element -> result[index] = element } + return result + } +} + +internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) + +internal actual fun Int.toSnapshotId(): SnapshotId = toLong() diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationDefinitionValidationTest.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationDefinitionValidationTest.kt index dd80402d3a2a6..24832892349da 100644 --- a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationDefinitionValidationTest.kt +++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationDefinitionValidationTest.kt @@ -16,7 +16,6 @@ package androidx.compose.runtime.changelist -import androidx.compose.runtime.changelist.Operation.IntParameter import androidx.compose.runtime.changelist.Operation.ObjectParameter import kotlin.reflect.KProperty1 import kotlin.reflect.full.declaredMemberProperties @@ -97,7 +96,7 @@ internal class OperationDefinitionValidationTest(private val oper val duplicateIntOffsets = intParams .groupBy( - keySelector = { (_, param) -> param.offset }, + keySelector = { (_, param) -> param }, valueTransform = { (name, _) -> name } ) .filterValues { it.size != 1 } @@ -140,8 +139,8 @@ internal class OperationDefinitionValidationTest(private val oper val outOfRangeInts = intParams.mapNotNull { (name, param) -> name - .takeIf { param.offset < 0 || param.offset >= intParams.size } - ?.let { paramName -> "$paramName (offset = ${param.offset})" } + .takeIf { param < 0 || param >= intParams.size } + ?.let { paramName -> "$paramName (offset = ${param})" } } if (outOfRangeInts.isNotEmpty()) { errors += diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt index 99e72a1077629..3254895226db9 100644 --- a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt +++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt @@ -16,7 +16,7 @@ package androidx.compose.runtime.changelist -import androidx.compose.runtime.changelist.Operation.IntParameter +import androidx.compose.runtime.EnableDebugRuntimeChecks import androidx.compose.runtime.changelist.Operation.ObjectParameter import androidx.compose.runtime.changelist.TestOperations.MixedOperation import androidx.compose.runtime.changelist.TestOperations.NoArgsOperation @@ -58,9 +58,14 @@ class OperationsTest { ) } - @Test(expected = IllegalArgumentException::class) + @Test fun testPush_withNoBlock_failsIfOperationHasArguments() { - stack.push(MixedOperation) + if (EnableDebugRuntimeChecks) { + try { + stack.push(MixedOperation) + fail("Pushing an operation should fail if it has arguments") + } catch (_: IllegalArgumentException) {} + } } @Test @@ -165,16 +170,16 @@ class OperationsTest { @Test fun testPush_withResizingRequired() { check( - stack.opCodes.size == Operations.InitialCapacity && - stack.intArgs.size == Operations.InitialCapacity && - stack.objectArgs.size == Operations.InitialCapacity + stack.opCodes.size == OperationsInitialCapacity && + stack.intArgs.size == OperationsInitialCapacity && + stack.objectArgs.size == OperationsInitialCapacity ) { "OpStack did not initialize one or more of its backing arrays (opCodes, intArgs, " + "or objectArgs) to `OpStack.InitialCapacity`. Please use the constant or update " + "this test with the correct capacity to ensure that resizing is being tested." } - val itemsToForceResize = Operations.InitialCapacity + 1 + val itemsToForceResize = OperationsInitialCapacity + 1 repeat(itemsToForceResize) { opNumber -> stack.push(TwoIntsOperation) { val (int1, int2) = TwoIntsOperation.intParams @@ -215,85 +220,93 @@ class OperationsTest { @Test fun testPush_throwsIfAnyIntArgsNotProvided() { - try { - stack.push(TwoIntsOperation) { - val (_, intArg2) = TwoIntsOperation.intParams - setInt(intArg2, 42) + if (EnableDebugRuntimeChecks) { + try { + stack.push(TwoIntsOperation) { + val (_, intArg2) = TwoIntsOperation.intParams + setInt(intArg2, 42) + } + fail( + "Pushing an operation that defines two parameters should fail " + + "if only one of the arguments is set" + ) + } catch (e: IllegalStateException) { + assertTrue( + message = + "The thrown exception does not appear to have reported the expected " + + "error (its message is '${e.message}')", + actual = e.message.orEmpty().contains("Not all arguments were provided") + ) } - fail( - "Pushing an operation that defines two parameters should fail " + - "if only one of the arguments is set" - ) - } catch (e: IllegalStateException) { - assertTrue( - message = - "The thrown exception does not appear to have reported the expected " + - "error (its message is '${e.message}')", - actual = e.message.orEmpty().contains("Not all arguments were provided") - ) } } @Test fun testPush_throwsIfAnyObjectArgsNotProvided() { - try { - stack.push(TwoObjectsOperation) { - val (_, objectArg2) = TwoObjectsOperation.objParams - setObject(objectArg2, Any()) + if (EnableDebugRuntimeChecks) { + try { + stack.push(TwoObjectsOperation) { + val (_, objectArg2) = TwoObjectsOperation.objParams + setObject(objectArg2, Any()) + } + fail( + "Pushing an operation that defines two parameters should fail " + + "if only one of the arguments is set" + ) + } catch (e: IllegalStateException) { + assertTrue( + message = + "The thrown exception does not appear to have reported the expected " + + "error (its message is '${e.message}')", + actual = e.message.orEmpty().contains("Not all arguments were provided") + ) } - fail( - "Pushing an operation that defines two parameters should fail " + - "if only one of the arguments is set" - ) - } catch (e: IllegalStateException) { - assertTrue( - message = - "The thrown exception does not appear to have reported the expected " + - "error (its message is '${e.message}')", - actual = e.message.orEmpty().contains("Not all arguments were provided") - ) } } @Test fun testPush_throwsIfIntArgProvidedTwice() { - try { - stack.push(ThreeIntsOperation) { - val (_, intArg2, _) = ThreeIntsOperation.intParams - setInt(intArg2, 2) - setInt(intArg2, 2) + if (EnableDebugRuntimeChecks) { + try { + stack.push(ThreeIntsOperation) { + val (_, intArg2, _) = ThreeIntsOperation.intParams + setInt(intArg2, 2) + setInt(intArg2, 2) + } + fail("Pushing an operation should fail if an argument is set twice") + } catch (e: IllegalStateException) { + assertTrue( + message = + "The thrown exception does not appear to have reported the expected " + + "error (its message is '${e.message}')", + actual = e.message.orEmpty().contains("Already pushed argument") + ) } - fail("Pushing an operation should fail if an argument is set twice") - } catch (e: IllegalStateException) { - assertTrue( - message = - "The thrown exception does not appear to have reported the expected " + - "error (its message is '${e.message}')", - actual = e.message.orEmpty().contains("Already pushed argument") - ) } } @Test fun testPush_throwsIfObjectArgProvidedTwice() { - try { - stack.push(ThreeObjectsOperation) { - val (_, objectArg2, _) = ThreeObjectsOperation.objParams - setObject(objectArg2, Any()) - setObject(objectArg2, Any()) + if (EnableDebugRuntimeChecks) { + try { + stack.push(ThreeObjectsOperation) { + val (_, objectArg2, _) = ThreeObjectsOperation.objParams + setObject(objectArg2, Any()) + setObject(objectArg2, Any()) + } + fail("Pushing an operation should fail if an argument is set twice") + } catch (e: IllegalStateException) { + assertTrue( + message = + "The thrown exception does not appear to have reported the expected " + + "error (its message is '${e.message}')", + actual = e.message.orEmpty().contains("Already pushed argument") + ) } - fail("Pushing an operation should fail if an argument is set twice") - } catch (e: IllegalStateException) { - assertTrue( - message = - "The thrown exception does not appear to have reported the expected " + - "error (its message is '${e.message}')", - actual = e.message.orEmpty().contains("Already pushed argument") - ) } } - @Test(expected = NoSuchElementException::class) + @Test(expected = IndexOutOfBoundsException::class) fun testPop_throwsIfStackIsEmpty() { stack.pop() } @@ -336,7 +349,7 @@ class OperationsTest { ) } - @Test(expected = NoSuchElementException::class) + @Test(expected = IndexOutOfBoundsException::class) fun testPopInto_throwsIfStackIsEmpty() { stack.pop() } @@ -412,7 +425,7 @@ class OperationsTest { @Test fun testClear_resetsToInitialState() { - val operationCount = Operations.InitialCapacity * 4 + val operationCount = OperationsInitialCapacity * 4 repeat(operationCount) { opNumber -> stack.push(MixedOperation) { val (int1, int2) = MixedOperation.intParams @@ -457,7 +470,7 @@ class OperationsTest { val capturedObjects = mutableListOf() stack.drain { capturedOperations += operation - repeat(operation.ints) { offset -> capturedInts += getInt(IntParameter(offset)) } + repeat(operation.ints) { offset -> capturedInts += getInt(offset) } repeat(operation.objects) { offset -> capturedObjects += getObject(ObjectParameter(offset)) } @@ -500,7 +513,7 @@ class OperationsTest { val capturedObjects = mutableListOf() stack.forEach { capturedOperations += operation - repeat(operation.ints) { offset -> capturedInts += getInt(IntParameter(offset)) } + repeat(operation.ints) { offset -> capturedInts += getInt(offset) } repeat(operation.objects) { offset -> capturedObjects += getObject(ObjectParameter(offset)) } diff --git a/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/SynchronizedObject.linux.kt b/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt similarity index 94% rename from compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/SynchronizedObject.linux.kt rename to compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt index 2250bc0d73e8a..3b75e07e248e5 100644 --- a/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/SynchronizedObject.linux.kt +++ b/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt @@ -14,6 +14,6 @@ * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.platform internal actual val PTHREAD_MUTEX_ERRORCHECK: Int = platform.posix.PTHREAD_MUTEX_ERRORCHECK.toInt() diff --git a/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/SynchronizedObject.mingwX64.kt b/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt similarity index 92% rename from compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/SynchronizedObject.mingwX64.kt rename to compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt index 1ec31765fec03..b5a1bc8fca5db 100644 --- a/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/SynchronizedObject.mingwX64.kt +++ b/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.platform import androidx.compose.runtime.internal.currentThreadId import kotlinx.atomicfu.* +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + @PublishedApi @Suppress("NON_PUBLIC_CALL_FROM_PUBLIC_INLINE") internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R { @@ -42,7 +44,7 @@ internal actual inline fun synchronized(lock: SynchronizedObject, block: () * Using a posix mutex is [problematic for mingwX64](https://youtrack.jetbrains.com/issue/KT-70449/Posix-declarations-differ-much-for-mingwX64-and-LinuxDarwin-targets), * so we just use a simple spin lock for mingwX64 (maybe reconsidered in case of problems). */ -internal actual class SynchronizedObject actual constructor() { +internal actual class SynchronizedObject { companion object { private const val NO_OWNER = -1L diff --git a/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt new file mode 100644 index 0000000000000..4ba29a491ca2e --- /dev/null +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt @@ -0,0 +1,141 @@ +/* + * 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. + */ + +@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER") + +package androidx.compose.runtime.snapshots + +import androidx.collection.mutableLongListOf + +actual typealias SnapshotId = Long + +actual const val SnapshotIdZero: SnapshotId = 0L +actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE +actual const val SnapshotIdSize: Int = Long.SIZE_BITS +actual const val SnapshotIdInvalidValue: SnapshotId = -1 + +actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = this.compareTo(other) + +actual inline operator fun SnapshotId.compareTo(other: Int): Int = this.compareTo(other.toLong()) + +actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() + +actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other + +actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() + +actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() + +actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() + +actual inline fun SnapshotId.toInt(): Int = this.toInt() + +actual typealias SnapshotIdArray = LongArray + +internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray = + LongArray(capacity) + +internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index] + +internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) { + this[index] = value +} + +internal actual inline val SnapshotIdArray.size: Int + get() = this.size + +internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) { + this.copyInto(other, 0) +} + +internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0] + +internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int { + var low = 0 + var high = size - 1 + + while (low <= high) { + val mid = (low + high).ushr(1) + val midVal = get(mid) + if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid + } + return -(low + 1) +} + +internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) { + for (value in this) { + block(value) + } +} + +internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray { + val newSize = size + 1 + val newArray = LongArray(newSize) + this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index) + this.copyInto( + destination = newArray, + destinationOffset = index + 1, + startIndex = index, + endIndex = newSize - 1 + ) + newArray[index] = id + return newArray +} + +internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? { + val newSize = this.size - 1 + if (newSize == 0) { + return null + } + val newArray = LongArray(newSize) + if (index > 0) { + this.copyInto( + destination = newArray, + destinationOffset = 0, + startIndex = 0, + endIndex = index + ) + } + if (index < newSize) { + this.copyInto( + destination = newArray, + destinationOffset = index, + startIndex = index + 1, + endIndex = newSize + 1 + ) + } + return newArray +} + +internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) { + private val list = array?.let { mutableLongListOf(*array) } ?: mutableLongListOf() + + actual fun add(id: SnapshotId) { + list.add(id) + } + + actual fun toArray(): SnapshotIdArray? { + val size = list.size + if (size == 0) return null + val result = LongArray(size) + list.forEachIndexed { index, element -> result[index] = element } + return result + } +} + +internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) + +internal actual fun Int.toSnapshotId(): SnapshotId = toLong() diff --git a/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/SynchronizationTest.kt b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt similarity index 98% rename from compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/SynchronizationTest.kt rename to compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt index 1e049e3bf3266..6cee1528ab611 100644 --- a/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/SynchronizationTest.kt +++ b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.platform import kotlin.concurrent.AtomicInt import kotlin.native.concurrent.* diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/AbstractApplierTest.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/AbstractApplierTest.kt index d83233c2f085e..94e8d84f83bb4 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/AbstractApplierTest.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/AbstractApplierTest.kt @@ -30,7 +30,7 @@ class AbstractApplierTest { try { applier.up() fail() - } catch (_: IllegalStateException) {} + } catch (_: IndexOutOfBoundsException) {} } @Test diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionLocalTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionLocalTests.kt index 711a073bfbdd2..7ec4fe3baa424 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionLocalTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionLocalTests.kt @@ -18,6 +18,7 @@ package androidx.compose.runtime import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentHashMapOf import androidx.compose.runtime.mock.EmptyApplier +import androidx.compose.runtime.mock.Linear import androidx.compose.runtime.mock.MockViewValidator import androidx.compose.runtime.mock.TestMonotonicFrameClock import androidx.compose.runtime.mock.Text @@ -679,6 +680,29 @@ class CompositionLocalTests { } } + @Test + fun testValueChangeWhileRemoving() = compositionTest { + val LocalText = compositionLocalOf { "" } + var showContent by mutableStateOf(true) + var text by mutableStateOf("Hello") + + compose { + if (showContent) { + Linear { + CompositionLocalProvider(LocalText provides text) { Text(LocalText.current) } + } + } + } + + validate { Linear { Text("Hello") } } + + text = "Goodbye" + showContent = false + expectChanges() + + validate { /* Empty Composition */ } + } + @Suppress("LocalVariableName") @Test // Validate androidx.compose.runtime.samples.compositionLocalComputedAfterProvidingLocal @@ -731,6 +755,51 @@ class CompositionLocalTests { compose { App() } } + + @Test // 374263387 + fun staticLocalUpdateInvalidatesCorrectly_startProvide() = compositionTest { + val LocalValue = staticCompositionLocalOf { error("Not provided") } + val LocalOtherValue = staticCompositionLocalOf { error("Not provided") } + var value by mutableStateOf(false) + var valueSeen = false + compose { + CompositionLocalProvider(LocalValue provides value) { + CompositionLocalProvider(LocalOtherValue providesDefault 1) { + CompositionLocalProvider(LocalOtherValue providesDefault 2) { + valueSeen = LocalValue.current + } + } + } + } + assertFalse(valueSeen) + value = true + advance() + assertTrue(valueSeen) + } + + fun staticLocalUpdateInvalidatesCorrectly_startProvides() = compositionTest { + val SomeValue = staticCompositionLocalOf { 0 } + val LocalValue = staticCompositionLocalOf { error("Not provided") } + val LocalOtherValue = staticCompositionLocalOf { error("Not provided") } + var value by mutableStateOf(false) + var valueSeen = false + compose { + CompositionLocalProvider(SomeValue provides 0, LocalValue provides value) { + CompositionLocalProvider(SomeValue provides 1, LocalOtherValue providesDefault 1) { + CompositionLocalProvider( + SomeValue provides 2, + LocalOtherValue providesDefault 2 + ) { + valueSeen = LocalValue.current + } + } + } + } + assertFalse(valueSeen) + value = true + advance() + assertTrue(valueSeen) + } } val cacheLocal = staticCompositionLocalOf { "Unset" } diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt index 82184235c4c15..bc6a8a81587ac 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt @@ -206,6 +206,43 @@ class CompositionTests { } } + @Test + fun testSeveralArbitraryMoves() = compositionTest { + val random = Random(1337) + val list = mutableStateListOf(*List(10) { it }.toTypedArray()) + + var position by mutableStateOf(-1) + + compose { + for (item in list) { + key(item) { Text("Item $item") } + } + } + + fun validate() { + validate { + for (item in list) { + Text("Item $item") + } + } + } + + validate() + + repeat(10) { + position = it + list.shuffle(random) + expectChanges() + validate() + list.first() + } + + position = -1 + list.shuffle(random) + expectChanges() + validate() + } + @Test fun testChangeTheFilter() = compositionTest { val model = testModel(mutableListOf(bob, steve, jon)) @@ -1669,6 +1706,60 @@ class CompositionTests { assertEquals(0, rememberObject2.count, "second object should have left") } + @Test + fun testRemember_forget_forgetAfterPredecessorReplaced() = compositionTest { + val rememberObject = + object : RememberObserver { + var count = 0 + + override fun onRemembered() { + count++ + } + + override fun onForgotten() { + count-- + } + + override fun onAbandoned() { + assertEquals(0, count, "onAbandon called after onRemember") + } + + override fun toString() = "rememberObject" + } + + var key = 0 + var include = true + var scope: RecomposeScope? = null + compose { + scope = currentRecomposeScope + if (include) { + Linear { + key(key) { Linear { Text("Some value") } } + use(remember { rememberObject }) + } + } + } + + fun MockViewValidator.Composition() { + if (include) Linear { Linear { Text("Some value") } } + } + + validate { this.Composition() } + assertEquals(1, rememberObject.count, "object should enter") + + key++ + scope?.invalidate() + expectChanges() + validate { Composition() } + assertEquals(1, rememberObject.count, "object should still be remembered") + + include = false + scope?.invalidate() + expectChanges() + validate { Composition() } + assertEquals(0, rememberObject.count, "object should have left") + } + @Test fun testRemember_RememberForgetNestedOrder() = compositionTest { var order = 0 diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt index 54fa6f6bcce22..1a5273be074c9 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt @@ -16,6 +16,7 @@ package androidx.compose.runtime +import androidx.compose.runtime.mock.InlineLinear import androidx.compose.runtime.mock.Linear import androidx.compose.runtime.mock.MockViewValidator import androidx.compose.runtime.mock.View @@ -1541,11 +1542,215 @@ class MovableContentTests { condition = false expectChanges() revalidate() + verifyConsistent() + + condition = true + expectChanges() + println("Done") + revalidate() + verifyConsistent() + } + + @Test // 362539770 + fun movableContent_nestedMovableContent_direct() = compositionTest { + var data = 0 + + var condition by mutableStateOf(true) + + val common = movableContentOf { + val state = remember { data++ } + Text("Generated state: $state") + } + + val wrapper = movableContentOf { + Text("Wrapper start") + common() + Text("Wrapper end") + } + + compose { + Text("Outer") + if (condition) { + wrapper() + } else { + common() + } + } + + validate { + Text("Outer") + if (condition) { + Text("Wrapper start") + } + Text("Generated state: 0") + if (condition) { + Text("Wrapper end") + } + } + + condition = false + expectChanges() + revalidate() + + condition = true + expectChanges() + revalidate() + } + + @Test // 362539770 + @OptIn(ExperimentalComposeApi::class) + fun movableContent_nestedMovableContent_disabled() = compositionTest { + var data = 0 + + var condition by mutableStateOf(true) + + val common = movableContentOf { + val state = remember { data++ } + Text("Generated state: $state") + } + + val wrapper = movableContentOf { + Text("Wrapper start") + common() + Text("Wrapper end") + } + + compose { + Text("Outer") + if (condition) { + wrapper() + } else { + common() + } + } + + var expectedState = 0 + validate { + Text("Outer") + if (condition) { + Text("Wrapper start") + } + Text("Generated state: $expectedState") + if (condition) { + Text("Wrapper end") + } + } + + ComposeRuntimeFlags.isMovingNestedMovableContentEnabled = false + try { + // With moving nested content disabled the call to common() will generate new + // state when it moves out of the containing movable content. + expectedState = 1 + condition = false + expectChanges() + revalidate() + + condition = true + expectChanges() + revalidate() + } finally { + ComposeRuntimeFlags.isMovingNestedMovableContentEnabled = true + } + } + + @Test + fun movableContent_nestedMovableContent_simpleMove() = compositionTest { + var data = 0 + + var condition by mutableStateOf(true) + + val common = movableContentOf { + val state = remember { data++ } + Text("Generated state: $state") + } + + val wrapper = movableContentOf { + Text("Wrapper start") + common() + Text("Wrapper end") + } + + compose { + Text("Outer") + if (condition) { + Linear { wrapper() } + } else { + wrapper() + } + } + + validate { + Text("Outer") + if (condition) { + Linear { + Text("Wrapper start") + Text("Generated state: 0") + Text("Wrapper end") + } + } else { + Text("Wrapper start") + Text("Generated state: 0") + Text("Wrapper end") + } + } + + condition = false + expectChanges() + revalidate() + condition = true expectChanges() revalidate() } + @Test + fun movableContent_nestedMovableContent_tree() = compositionTest { + var data = 0 + + @Composable + fun Leaf() { + val value = remember { data++ } + Text("Data $value") + } + + val level0 = Array(16) { movableContentOf { Leaf() } } + val level1 = + Array(8) { it -> + movableContentOf { + level0[it * 2]() + level0[it * 2 + 1]() + } + } + val level2 = + Array(4) { + movableContentOf { + level1[it * 2]() + level1[it * 2 + 1]() + } + } + val level3 = + Array(2) { + movableContentOf { + level2[it * 2]() + level2[it * 2 + 1]() + } + } + + var displayTree by mutableStateOf(false) + + compose { if (displayTree) level3.forEach { it() } else level0.forEach { it() } } + + validate { repeat(16) { Text("Data $it") } } + + displayTree = true + expectChanges() + revalidate() + + displayTree = false + expectChanges() + revalidate() + } + @Test // 343178423 fun movableContent_movingContentOutOfDeferredSubcomposition() = compositionTest { var toggle by mutableStateOf(true) @@ -1572,6 +1777,37 @@ class MovableContentTests { expectChanges() revalidate() } + + @Test // 365802563 + fun movableContent_movingChildOfDeletedNode() = compositionTest { + var index by mutableIntStateOf(0) + val content = movableContentOf { Text("Some text") } + compose { + for (i in index..3) { + InlineLinear { content() } + } + for (i in 0..index) { + InlineLinear { content() } + } + } + validate { + for (i in index..3) { + Linear { Text("Some text") } + } + for (i in 0..index) { + Linear { Text("Some text") } + } + } + + index++ + advance() + + index++ + advance() + + index++ + advance() + } } @Composable diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt index 80058fcf655ed..28842faf083a0 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt @@ -25,6 +25,8 @@ import androidx.compose.runtime.mock.ViewApplier import androidx.compose.runtime.mock.compositionTest import androidx.compose.runtime.mock.validate import androidx.compose.runtime.mock.view +import androidx.compose.runtime.platform.SynchronizedObject +import androidx.compose.runtime.platform.synchronized import kotlin.coroutines.resume import kotlin.test.Ignore import kotlin.test.Test diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt index e87bc990a9875..b645d39692a73 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt @@ -35,9 +35,9 @@ class SnapshotDoubleIndexHeapTests { fun canAddAndRemoveNumbersInSequence() { val heap = SnapshotDoubleIndexHeap() val handles = IntArray(100) - repeat(100) { handles[it] = heap.add(it) } + repeat(100) { handles[it] = heap.add(it.toLong()) } repeat(100) { - assertEquals(it, heap.lowestOrDefault(-1)) + assertEquals(it.toLong(), heap.lowestOrDefault(-1)) heap.remove(handles[it]) } assertEquals(0, heap.size) @@ -55,7 +55,7 @@ class SnapshotDoubleIndexHeapTests { if (shouldAdd) { val indexToAdd = random.nextInt(toAdd.size) val value = toAdd[indexToAdd] - val handle = heap.add(value) + val handle = heap.add(value.toLong()) toRemove.add(value to handle) toAdd.removeAt(indexToAdd) } else { @@ -68,11 +68,11 @@ class SnapshotDoubleIndexHeapTests { heap.validate() for ((value, handle) in toRemove) { - heap.validateHandle(handle, value) + heap.validateHandle(handle, value.toLong()) } val lowestAdded = toRemove.fold(400) { lowest, (value, _) -> if (value < lowest) value else lowest } - assertEquals(lowestAdded, heap.lowestOrDefault(400)) + assertEquals(lowestAdded, heap.lowestOrDefault(400).toInt()) } } } diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt index c1e7710ef6266..c4cf6c2b382e7 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt @@ -16,9 +16,12 @@ package androidx.compose.runtime.snapshots +import androidx.collection.LongList +import androidx.collection.mutableLongListOf import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class SnapshotIdSetTests { @@ -26,12 +29,12 @@ class SnapshotIdSetTests { fun emptySetShouldBeEmpty() { val empty = SnapshotIdSet.EMPTY - repeat(1000) { empty.shouldBe(it, false) } + repeat(1000L) { empty.shouldBe(it, false) } } @Test fun shouldBeAbleToSetItems() { - val times = 10000 + val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) } repeat(times) { set.shouldBe(it, true) } @@ -39,42 +42,42 @@ class SnapshotIdSetTests { @Test fun shouldBeAbleToSetOnlyEven() { - val times = 10000 + val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - if (index % 2 == 0) prev.set(index) else prev + if (index % 2L == 0L) prev.set(index) else prev } - repeat(times) { set.shouldBe(it, it % 2 == 0) } + repeat(times) { set.shouldBe(it, it % 2L == 0L) } } @Test fun shouldBeAbleToSetOnlyOdds() { - val times = 10000 + val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - if (index % 2 == 1) prev.set(index) else prev + if (index % 2L == 1L) prev.set(index) else prev } - repeat(times) { set.shouldBe(it, it % 2 == 1) } + repeat(times) { set.shouldBe(it, it % 2L == 1L) } } @Test fun shouldBeAbleToClearEvens() { - val times = 10000 + val times = 10000L val allSet = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) } val set = (0..times).fold(allSet) { prev, index -> - if (index % 2 == 0) prev.clear(index) else prev + if (index % 2L == 0L) prev.clear(index) else prev } - repeat(times - 1) { set.shouldBe(it, it % 2 == 1) } + repeat(times - 1) { set.shouldBe(it, it % 2L == 1L) } } @Test fun shouldBeAbleToCrawlSet() { - val times = 10000 + val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.clear(index - 1).set(index) } @@ -84,16 +87,16 @@ class SnapshotIdSetTests { @Test fun shouldBeAbleToCrawlAndClear() { - val times = 10000 + val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - prev.let { if ((index - 1) % 33 != 0) it.clear(index - 1) else it }.set(index) + prev.let { if ((index - 1L) % 33L != 0L) it.clear(index - 1) else it }.set(index) } set.shouldBe(times, true) // The multiples of 33 items should now be set - repeat(times - 1) { set.shouldBe(it, it % 33 == 0) } + repeat(times - 1) { set.shouldBe(it, it % 33L == 0L) } val newSet = (0 until times).fold(set) { prev, index -> prev.clear(index) } @@ -104,23 +107,23 @@ class SnapshotIdSetTests { @Test fun shouldBeAbleToInsertAndRemoveOutOfOptimalRange() { - SnapshotIdSet.EMPTY.set(1000) - .set(1) - .shouldBe(1000, true) - .shouldBe(1, true) - .set(10) - .shouldBe(10, true) - .set(4) - .shouldBe(4, true) - .clear(1) - .shouldBe(1, false) - .clear(4) - .shouldBe(4, false) - .clear(10) - .shouldBe(1, false) - .shouldBe(4, false) - .shouldBe(10, false) - .shouldBe(1000, true) + SnapshotIdSet.EMPTY.set(1000L) + .set(1L) + .shouldBe(1000L, true) + .shouldBe(1L, true) + .set(10L) + .shouldBe(10L, true) + .set(4L) + .shouldBe(4L, true) + .clear(1L) + .shouldBe(1L, false) + .clear(4L) + .shouldBe(4L, false) + .clear(10L) + .shouldBe(1L, false) + .shouldBe(4L, false) + .shouldBe(10L, false) + .shouldBe(1000L, true) } @Test @@ -128,20 +131,20 @@ class SnapshotIdSetTests { val random = Random(10) val booleans = BooleanArray(1000) val set = - (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ -> + (0..100L).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = true - prev.set(value) + prev.set(value.toLong()) } val clear = (0..100).fold(set) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = false - prev.clear(value) + prev.clear(value.toLong()) } - repeat(1000) { clear.shouldBe(it, booleans[it]) } + repeat(1000L) { clear.shouldBe(it, booleans[it.toInt()]) } } @Test @@ -152,18 +155,18 @@ class SnapshotIdSetTests { (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = true - prev.set(value) + prev.set(value.toLong()) } val setB = (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = false - prev.set(value) + prev.set(value.toLong()) } val set = setA.andNot(setB) - repeat(1000) { set.shouldBe(it, booleans[it]) } + repeat(1000L) { set.shouldBe(it, booleans[it.toInt()]) } } @Test @@ -175,18 +178,18 @@ class SnapshotIdSetTests { (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index) + prev.set(index.toLong()) } else prev } val setB = (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = false - prev.set(index) + prev.set(index.toLong()) } else prev } val set = setA.andNot(setB) - repeat(size) { set.shouldBe(it, booleans[it]) } + repeat(size) { set.shouldBe(it.toLong(), booleans[it]) } } test(32) test(64) @@ -204,18 +207,18 @@ class SnapshotIdSetTests { (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index) + prev.set(index.toLong()) } else prev } val setB = (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index) + prev.set(index.toLong()) } else prev } val set = setA.or(setB) - repeat(size) { set.shouldBe(it, booleans[it]) } + repeat(size) { set.shouldBe(it.toLong(), booleans[it]) } } test(32) test(64) @@ -228,12 +231,12 @@ class SnapshotIdSetTests { fun shouldBeAbleToIterate() { fun test(size: Int) { val random = Random(size) - val values = mutableListOf() + val values = mutableLongListOf() val set = (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { - values.add(index) - prev.set(index) + values.add(index.toLong()) + prev.set(index.toLong()) } else prev } values.zip(set).forEach { assertEquals(it.first, it.second) } @@ -248,7 +251,7 @@ class SnapshotIdSetTests { @Test // Regression b/182822837 fun shouldReportTheCorrectLowest() { - fun test(number: Int) { + fun test(number: Long) { val set = SnapshotIdSet.EMPTY.set(number) assertEquals(number, set.lowest(-1)) } @@ -256,6 +259,14 @@ class SnapshotIdSetTests { repeat(64) { test(it) } } + @Test + fun shouldOverflowGracefully() { + val s = SnapshotIdSet.EMPTY.set(0).set(Long.MAX_VALUE) + assertTrue(s.get(0)) + assertTrue(s.get(Long.MAX_VALUE)) + assertFalse(s.get(1)) + } + @Test // Regression: b/147836978 fun shouldValidWhenSetIsLarge() { val data = @@ -5615,14 +5626,33 @@ class SnapshotIdSetTests { .filter { it.isNotEmpty() } .map { it.split(":").let { it[0].toInt() to it[1].toBoolean() } } operations.fold(SnapshotIdSet.EMPTY) { prev, (value, op) -> - assertTrue(prev.get(value) != op, "Error on bit $value, expected ${!op}, received $op") - val result = if (op) prev.set(value) else prev.clear(value) + assertTrue( + prev.get(value.toLong()) != op, + "Error on bit $value, expected ${!op}, received $op" + ) + val result = if (op) prev.set(value.toLong()) else prev.clear(value.toLong()) result } } } -private fun SnapshotIdSet.shouldBe(index: Int, value: Boolean): SnapshotIdSet { +private fun SnapshotIdSet.shouldBe(index: Long, value: Boolean): SnapshotIdSet { assertEquals(value, get(index), "Bit $index should be $value") return this } + +private inline fun repeat(times: Long, action: (Long) -> Unit) { + for (index in 0 until times) { + action(index) + } +} + +private fun LongList.zip(other: Iterable): List> { + val second = other.iterator() + val list = mutableListOf>() + forEach { + if (!second.hasNext()) return@forEach + list.add(it to second.next()) + } + return list +} diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt index 34abf1176ce46..166c2afa3b779 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt @@ -716,7 +716,7 @@ class SnapshotTests { parent.apply().check() parent.dispose() snapshot.enter { - // Should se the change of state1 + // Should see the change of state1 assertEquals(1, state1) // But not the state change of state2 diff --git a/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt b/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt index 54c03b12c5a9d..45485f966ba98 100644 --- a/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.mock.compositionTest import org.junit.After import org.junit.Assert import org.junit.Before -import org.junit.Ignore import org.junit.Test class LiveEditTests { @@ -50,8 +49,7 @@ class LiveEditTests { } } - // TODO: This should pass but doesn't. Need to investigate why. - @Ignore + @Test fun testNonRestartableTargetAtRootScope() = liveEditTest { Target("b", restartable = false) } @Test diff --git a/compose/runtime/runtime/src/posixMain/kotlin/androidx/compose/runtime/SynchronizedObject.posix.kt b/compose/runtime/runtime/src/posixMain/kotlin/androidx/compose/runtime/platform/Synchronization.posix.kt similarity index 97% rename from compose/runtime/runtime/src/posixMain/kotlin/androidx/compose/runtime/SynchronizedObject.posix.kt rename to compose/runtime/runtime/src/posixMain/kotlin/androidx/compose/runtime/platform/Synchronization.posix.kt index 4a1e43c3c282f..0d8c9cf5523a0 100644 --- a/compose/runtime/runtime/src/posixMain/kotlin/androidx/compose/runtime/SynchronizedObject.posix.kt +++ b/compose/runtime/runtime/src/posixMain/kotlin/androidx/compose/runtime/platform/Synchronization.posix.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.platform import androidx.compose.runtime.internal.currentThreadId import kotlin.native.ref.createCleaner @@ -62,7 +62,7 @@ internal expect val PTHREAD_MUTEX_ERRORCHECK: Int * On the other hand, it does not just spin lock in case of contention, * protecting from an occasional battery drain. */ -internal actual class SynchronizedObject actual constructor() { +internal actual class SynchronizedObject { companion object { private const val NO_OWNER = -1L @@ -156,6 +156,8 @@ internal actual class SynchronizedObject actual constructor() { } } +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + @PublishedApi @Suppress("NON_PUBLIC_CALL_FROM_PUBLIC_INLINE") internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R { diff --git a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasm.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasm.kt index ff9954f08be5e..f40e44aaf8eaf 100644 --- a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasm.kt +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasm.kt @@ -16,13 +16,6 @@ package androidx.compose.runtime -import kotlinx.browser.window -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlin.time.DurationUnit -import kotlin.time.ExperimentalTime -import kotlin.time.toDuration - @JsFun("(obj, index) => obj[index]") private external fun dynamicGetInt(obj: JsAny, index: String): Int? diff --git a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt new file mode 100644 index 0000000000000..4ba29a491ca2e --- /dev/null +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt @@ -0,0 +1,141 @@ +/* + * 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. + */ + +@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER") + +package androidx.compose.runtime.snapshots + +import androidx.collection.mutableLongListOf + +actual typealias SnapshotId = Long + +actual const val SnapshotIdZero: SnapshotId = 0L +actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE +actual const val SnapshotIdSize: Int = Long.SIZE_BITS +actual const val SnapshotIdInvalidValue: SnapshotId = -1 + +actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = this.compareTo(other) + +actual inline operator fun SnapshotId.compareTo(other: Int): Int = this.compareTo(other.toLong()) + +actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() + +actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other + +actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() + +actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() + +actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() + +actual inline fun SnapshotId.toInt(): Int = this.toInt() + +actual typealias SnapshotIdArray = LongArray + +internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray = + LongArray(capacity) + +internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index] + +internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) { + this[index] = value +} + +internal actual inline val SnapshotIdArray.size: Int + get() = this.size + +internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) { + this.copyInto(other, 0) +} + +internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0] + +internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int { + var low = 0 + var high = size - 1 + + while (low <= high) { + val mid = (low + high).ushr(1) + val midVal = get(mid) + if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid + } + return -(low + 1) +} + +internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) { + for (value in this) { + block(value) + } +} + +internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray { + val newSize = size + 1 + val newArray = LongArray(newSize) + this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index) + this.copyInto( + destination = newArray, + destinationOffset = index + 1, + startIndex = index, + endIndex = newSize - 1 + ) + newArray[index] = id + return newArray +} + +internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? { + val newSize = this.size - 1 + if (newSize == 0) { + return null + } + val newArray = LongArray(newSize) + if (index > 0) { + this.copyInto( + destination = newArray, + destinationOffset = 0, + startIndex = 0, + endIndex = index + ) + } + if (index < newSize) { + this.copyInto( + destination = newArray, + destinationOffset = index, + startIndex = index + 1, + endIndex = newSize + 1 + ) + } + return newArray +} + +internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) { + private val list = array?.let { mutableLongListOf(*array) } ?: mutableLongListOf() + + actual fun add(id: SnapshotId) { + list.add(id) + } + + actual fun toArray(): SnapshotIdArray? { + val size = list.size + if (size == 0) return null + val result = LongArray(size) + list.forEachIndexed { index, element -> result[index] = element } + return result + } +} + +internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) + +internal actual fun Int.toSnapshotId(): SnapshotId = toLong() diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/ActualJsWasm.web.kt b/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/WeakReference.web.kt similarity index 100% rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/ActualJsWasm.web.kt rename to compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/WeakReference.web.kt diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/SynchronizedObject.web.kt b/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/platform/Synchronization.web.kt similarity index 84% rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/SynchronizedObject.web.kt rename to compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/platform/Synchronization.web.kt index 10a1a5f0e0ea7..3be862ddafc0d 100644 --- a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/SynchronizedObject.web.kt +++ b/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/platform/Synchronization.web.kt @@ -14,10 +14,13 @@ * limitations under the License. */ -package androidx.compose.runtime +package androidx.compose.runtime.platform @Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 internal actual typealias SynchronizedObject = Any +@Suppress("NOTHING_TO_INLINE") +internal actual inline fun makeSynchronizedObject(ref: Any?) = ref ?: SynchronizedObject() + @PublishedApi internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R = block() diff --git a/compose/ui/ui-geometry/api/current.ignore b/compose/ui/ui-geometry/api/current.ignore new file mode 100644 index 0000000000000..55671879be205 --- /dev/null +++ b/compose/ui/ui-geometry/api/current.ignore @@ -0,0 +1,17 @@ +// Baseline format: 1.0 +RemovedMethod: androidx.compose.ui.geometry.CornerRadius#getX(): + Removed method androidx.compose.ui.geometry.CornerRadius.getX() +RemovedMethod: androidx.compose.ui.geometry.CornerRadius#getY(): + Removed method androidx.compose.ui.geometry.CornerRadius.getY() +RemovedMethod: androidx.compose.ui.geometry.Offset#getX(): + Removed method androidx.compose.ui.geometry.Offset.getX() +RemovedMethod: androidx.compose.ui.geometry.Offset#getY(): + Removed method androidx.compose.ui.geometry.Offset.getY() +RemovedMethod: androidx.compose.ui.geometry.Size#getHeight(): + Removed method androidx.compose.ui.geometry.Size.getHeight() +RemovedMethod: androidx.compose.ui.geometry.Size#getMaxDimension(): + Removed method androidx.compose.ui.geometry.Size.getMaxDimension() +RemovedMethod: androidx.compose.ui.geometry.Size#getMinDimension(): + Removed method androidx.compose.ui.geometry.Size.getMinDimension() +RemovedMethod: androidx.compose.ui.geometry.Size#getWidth(): + Removed method androidx.compose.ui.geometry.Size.getWidth() diff --git a/compose/ui/ui-geometry/api/current.txt b/compose/ui/ui-geometry/api/current.txt index 3a2dac50f19f4..bedee07d01714 100644 --- a/compose/ui/ui-geometry/api/current.txt +++ b/compose/ui/ui-geometry/api/current.txt @@ -8,8 +8,6 @@ package androidx.compose.ui.geometry { method public long copy(optional float x, optional float y); method @androidx.compose.runtime.Stable public operator long div(float operand); method public long getPackedValue(); - method public inline float getX(); - method public inline float getY(); method @androidx.compose.runtime.Stable public inline boolean isCircular(); method @androidx.compose.runtime.Stable public inline boolean isZero(); method @androidx.compose.runtime.Stable public operator long minus(long other); @@ -24,7 +22,7 @@ package androidx.compose.ui.geometry { public static final class CornerRadius.Companion { method public long getZero(); - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Zero; } public final class CornerRadiusKt { @@ -107,8 +105,6 @@ package androidx.compose.ui.geometry { method @androidx.compose.runtime.Stable public float getDistance(); method @androidx.compose.runtime.Stable public float getDistanceSquared(); method public long getPackedValue(); - method public inline float getX(); - method public inline float getY(); method @androidx.compose.runtime.Stable public inline boolean isValid(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); @@ -125,9 +121,9 @@ package androidx.compose.ui.geometry { method public long getInfinite(); method public long getUnspecified(); method public long getZero(); - property public final long Infinite; - property public final long Unspecified; - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Infinite; + property @androidx.compose.runtime.Stable public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Zero; } public final class OffsetKt { @@ -175,7 +171,7 @@ package androidx.compose.ui.geometry { method public boolean overlaps(androidx.compose.ui.geometry.Rect other); method @androidx.compose.runtime.Stable public androidx.compose.ui.geometry.Rect translate(float translateX, float translateY); method @androidx.compose.runtime.Stable public androidx.compose.ui.geometry.Rect translate(long offset); - property public final float bottom; + property @androidx.compose.runtime.Stable public final float bottom; property public final long bottomCenter; property public final long bottomLeft; property public final long bottomRight; @@ -186,12 +182,12 @@ package androidx.compose.ui.geometry { property @androidx.compose.runtime.Stable public final boolean isEmpty; property @androidx.compose.runtime.Stable public final boolean isFinite; property @androidx.compose.runtime.Stable public final boolean isInfinite; - property public final float left; + property @androidx.compose.runtime.Stable public final float left; property public final float maxDimension; property public final float minDimension; - property public final float right; + property @androidx.compose.runtime.Stable public final float right; property @androidx.compose.runtime.Stable public final long size; - property public final float top; + property @androidx.compose.runtime.Stable public final float top; property public final long topCenter; property public final long topLeft; property public final long topRight; @@ -201,7 +197,7 @@ package androidx.compose.ui.geometry { public static final class Rect.Companion { method public androidx.compose.ui.geometry.Rect getZero(); - property public final androidx.compose.ui.geometry.Rect Zero; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.geometry.Rect Zero; } public final class RectKt { @@ -234,7 +230,6 @@ package androidx.compose.ui.geometry { method public long getTopRightCornerRadius(); method public float getWidth(); method public static androidx.compose.ui.geometry.RoundRect getZero(); - property public static final androidx.compose.ui.geometry.RoundRect Zero; property public final float bottom; property public final long bottomLeftCornerRadius; property public final long bottomRightCornerRadius; @@ -280,11 +275,7 @@ package androidx.compose.ui.geometry { method @androidx.compose.runtime.Stable public inline operator float component2(); method public long copy(optional float width, optional float height); method @androidx.compose.runtime.Stable public operator long div(float operand); - method public inline float getHeight(); - method public float getMaxDimension(); - method public float getMinDimension(); method public long getPackedValue(); - method public inline float getWidth(); method @androidx.compose.runtime.Stable public boolean isEmpty(); method @androidx.compose.runtime.Stable public operator long times(float operand); property @androidx.compose.runtime.Stable public final inline float height; @@ -298,8 +289,8 @@ package androidx.compose.ui.geometry { public static final class Size.Companion { method public long getUnspecified(); method public long getZero(); - property public final long Unspecified; - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Zero; } public final class SizeKt { diff --git a/compose/ui/ui-geometry/api/desktop/ui-geometry.api b/compose/ui/ui-geometry/api/desktop/ui-geometry.api index 56efffc52b1fe..e3ea1de9035ef 100644 --- a/compose/ui/ui-geometry/api/desktop/ui-geometry.api +++ b/compose/ui/ui-geometry/api/desktop/ui-geometry.api @@ -40,6 +40,7 @@ public final class androidx/compose/ui/geometry/InlineClassHelperKt { public static final field DualFirstNaN J public static final field DualFloatInfinityBase J public static final field DualFloatSignBit J + public static final field DualLoadedSignificand J public static final field DualUnsignedFloatMask J public static final field FloatInfinityBase I public static final field Uint64High32 J diff --git a/compose/ui/ui-geometry/api/restricted_current.ignore b/compose/ui/ui-geometry/api/restricted_current.ignore new file mode 100644 index 0000000000000..55671879be205 --- /dev/null +++ b/compose/ui/ui-geometry/api/restricted_current.ignore @@ -0,0 +1,17 @@ +// Baseline format: 1.0 +RemovedMethod: androidx.compose.ui.geometry.CornerRadius#getX(): + Removed method androidx.compose.ui.geometry.CornerRadius.getX() +RemovedMethod: androidx.compose.ui.geometry.CornerRadius#getY(): + Removed method androidx.compose.ui.geometry.CornerRadius.getY() +RemovedMethod: androidx.compose.ui.geometry.Offset#getX(): + Removed method androidx.compose.ui.geometry.Offset.getX() +RemovedMethod: androidx.compose.ui.geometry.Offset#getY(): + Removed method androidx.compose.ui.geometry.Offset.getY() +RemovedMethod: androidx.compose.ui.geometry.Size#getHeight(): + Removed method androidx.compose.ui.geometry.Size.getHeight() +RemovedMethod: androidx.compose.ui.geometry.Size#getMaxDimension(): + Removed method androidx.compose.ui.geometry.Size.getMaxDimension() +RemovedMethod: androidx.compose.ui.geometry.Size#getMinDimension(): + Removed method androidx.compose.ui.geometry.Size.getMinDimension() +RemovedMethod: androidx.compose.ui.geometry.Size#getWidth(): + Removed method androidx.compose.ui.geometry.Size.getWidth() diff --git a/compose/ui/ui-geometry/api/restricted_current.txt b/compose/ui/ui-geometry/api/restricted_current.txt index 13de29eca2366..82e608260f9c0 100644 --- a/compose/ui/ui-geometry/api/restricted_current.txt +++ b/compose/ui/ui-geometry/api/restricted_current.txt @@ -8,8 +8,6 @@ package androidx.compose.ui.geometry { method public long copy(optional float x, optional float y); method @androidx.compose.runtime.Stable public operator long div(float operand); method public long getPackedValue(); - method public inline float getX(); - method public inline float getY(); method @androidx.compose.runtime.Stable public inline boolean isCircular(); method @androidx.compose.runtime.Stable public inline boolean isZero(); method @androidx.compose.runtime.Stable public operator long minus(long other); @@ -24,7 +22,7 @@ package androidx.compose.ui.geometry { public static final class CornerRadius.Companion { method public long getZero(); - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Zero; } public final class CornerRadiusKt { @@ -33,9 +31,19 @@ package androidx.compose.ui.geometry { } public final class InlineClassHelperKt { + property @kotlin.PublishedApi internal static final long DualFirstNaN; + property @kotlin.PublishedApi internal static final long DualFloatInfinityBase; + property @kotlin.PublishedApi internal static final long DualFloatSignBit; + property @kotlin.PublishedApi internal static final long DualLoadedSignificand; + property @kotlin.PublishedApi internal static final long DualUnsignedFloatMask; + property @kotlin.PublishedApi internal static final int FloatInfinityBase; + property @kotlin.PublishedApi internal static final long Uint64High32; + property @kotlin.PublishedApi internal static final long Uint64Low32; + property @kotlin.PublishedApi internal static final long UnspecifiedPackedFloats; field @kotlin.PublishedApi internal static final long DualFirstNaN = 9187343246269874177L; // 0x7f8000017f800001L field @kotlin.PublishedApi internal static final long DualFloatInfinityBase = 9187343241974906880L; // 0x7f8000007f800000L field @kotlin.PublishedApi internal static final long DualFloatSignBit = -9223372034707292160L; // 0x8000000080000000L + field @kotlin.PublishedApi internal static final long DualLoadedSignificand = 36028792732385279L; // 0x7fffff007fffffL field @kotlin.PublishedApi internal static final long DualUnsignedFloatMask = 9223372034707292159L; // 0x7fffffff7fffffffL field @kotlin.PublishedApi internal static final int FloatInfinityBase = 2139095040; // 0x7f800000 field @kotlin.PublishedApi internal static final long Uint64High32 = -9223372034707292160L; // 0x8000000080000000L @@ -118,8 +126,6 @@ package androidx.compose.ui.geometry { method @androidx.compose.runtime.Stable public float getDistance(); method @androidx.compose.runtime.Stable public float getDistanceSquared(); method public long getPackedValue(); - method public inline float getX(); - method public inline float getY(); method @androidx.compose.runtime.Stable public inline boolean isValid(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); @@ -136,9 +142,9 @@ package androidx.compose.ui.geometry { method public long getInfinite(); method public long getUnspecified(); method public long getZero(); - property public final long Infinite; - property public final long Unspecified; - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Infinite; + property @androidx.compose.runtime.Stable public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Zero; } public final class OffsetKt { @@ -186,7 +192,7 @@ package androidx.compose.ui.geometry { method public boolean overlaps(androidx.compose.ui.geometry.Rect other); method @androidx.compose.runtime.Stable public androidx.compose.ui.geometry.Rect translate(float translateX, float translateY); method @androidx.compose.runtime.Stable public androidx.compose.ui.geometry.Rect translate(long offset); - property public final float bottom; + property @androidx.compose.runtime.Stable public final float bottom; property public final long bottomCenter; property public final long bottomLeft; property public final long bottomRight; @@ -197,12 +203,12 @@ package androidx.compose.ui.geometry { property @androidx.compose.runtime.Stable public final boolean isEmpty; property @androidx.compose.runtime.Stable public final boolean isFinite; property @androidx.compose.runtime.Stable public final boolean isInfinite; - property public final float left; + property @androidx.compose.runtime.Stable public final float left; property public final float maxDimension; property public final float minDimension; - property public final float right; + property @androidx.compose.runtime.Stable public final float right; property @androidx.compose.runtime.Stable public final long size; - property public final float top; + property @androidx.compose.runtime.Stable public final float top; property public final long topCenter; property public final long topLeft; property public final long topRight; @@ -212,7 +218,7 @@ package androidx.compose.ui.geometry { public static final class Rect.Companion { method public androidx.compose.ui.geometry.Rect getZero(); - property public final androidx.compose.ui.geometry.Rect Zero; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.geometry.Rect Zero; } public final class RectKt { @@ -245,7 +251,6 @@ package androidx.compose.ui.geometry { method public long getTopRightCornerRadius(); method public float getWidth(); method public static androidx.compose.ui.geometry.RoundRect getZero(); - property public static final androidx.compose.ui.geometry.RoundRect Zero; property public final float bottom; property public final long bottomLeftCornerRadius; property public final long bottomRightCornerRadius; @@ -291,11 +296,7 @@ package androidx.compose.ui.geometry { method @androidx.compose.runtime.Stable public inline operator float component2(); method public long copy(optional float width, optional float height); method @androidx.compose.runtime.Stable public operator long div(float operand); - method public inline float getHeight(); - method public float getMaxDimension(); - method public float getMinDimension(); method public long getPackedValue(); - method public inline float getWidth(); method @androidx.compose.runtime.Stable public boolean isEmpty(); method @androidx.compose.runtime.Stable public operator long times(float operand); property @androidx.compose.runtime.Stable public final inline float height; @@ -309,8 +310,8 @@ package androidx.compose.ui.geometry { public static final class Size.Companion { method public long getUnspecified(); method public long getZero(); - property public final long Unspecified; - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Zero; } public final class SizeKt { diff --git a/compose/ui/ui-geometry/src/androidUnitTest/kotlin/androidx/compose/ui/geometry/OffsetTest.kt b/compose/ui/ui-geometry/src/androidUnitTest/kotlin/androidx/compose/ui/geometry/OffsetTest.kt index 6bcd179e02587..cacc8981f0748 100644 --- a/compose/ui/ui-geometry/src/androidUnitTest/kotlin/androidx/compose/ui/geometry/OffsetTest.kt +++ b/compose/ui/ui-geometry/src/androidUnitTest/kotlin/androidx/compose/ui/geometry/OffsetTest.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("RedundantSuppression") + package androidx.compose.ui.geometry import androidx.compose.ui.util.floatFromBits @@ -57,7 +59,7 @@ class OffsetTest { try { Offset.Unspecified.x Assert.fail("Offset.Unspecified.x is not allowed") - } catch (t: Throwable) { + } catch (_: Throwable) { // no-op } } @@ -67,7 +69,7 @@ class OffsetTest { try { Offset.Unspecified.y Assert.fail("Offset.Unspecified.y is not allowed") - } catch (t: Throwable) { + } catch (_: Throwable) { // no-op } } @@ -78,7 +80,7 @@ class OffsetTest { Offset.Unspecified.copy(x = 100f) Offset.Unspecified.copy(y = 70f) Assert.fail("Offset.Unspecified.copy is not allowed") - } catch (t: Throwable) { + } catch (_: Throwable) { // no-op } } @@ -88,7 +90,7 @@ class OffsetTest { try { val (_, _) = Offset.Unspecified Assert.fail("Size.Unspecified component assignment is not allowed") - } catch (t: Throwable) { + } catch (_: Throwable) { // no-op } } diff --git a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/InlineClassHelper.kt b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/InlineClassHelper.kt index 8338bd228a06b..55e34637a0ae2 100644 --- a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/InlineClassHelper.kt +++ b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/InlineClassHelper.kt @@ -40,3 +40,6 @@ package androidx.compose.ui.geometry // Encodes the first valid NaN in each of the 32 bit chunk of a 64 bit word @PublishedApi internal const val DualFirstNaN = 0x7f800001_7f800001L + +// Set all the significand bits for each 32 bit chunk in a 64 bit word +@PublishedApi internal const val DualLoadedSignificand = 0x007fffff_007fffffL diff --git a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/Offset.kt b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/Offset.kt index 100212dd63282..5eca5f4861ab0 100644 --- a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/Offset.kt +++ b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/Offset.kt @@ -107,9 +107,9 @@ value class Offset(val packedValue: Long) { */ @Stable inline fun isValid(): Boolean { - // Take the unsigned packed floats and see if they are < InfinityBase + 1 (first NaN) + // Take the unsigned packed floats and see if they are > InfinityBase (any NaN) val v = packedValue and DualUnsignedFloatMask - return (v - DualFirstNaN) and Uint64High32 == Uint64High32 + return (v + DualLoadedSignificand) and Uint64High32 == 0L } /** diff --git a/compose/ui/ui-graphics/api/current.ignore b/compose/ui/ui-graphics/api/current.ignore index 90bda4dc80b8e..4fd18cac0409e 100644 --- a/compose/ui/ui-graphics/api/current.ignore +++ b/compose/ui/ui-graphics/api/current.ignore @@ -1,11 +1,13 @@ // Baseline format: 1.0 -AddedAbstractMethod: androidx.compose.ui.graphics.Path#addOval(androidx.compose.ui.geometry.Rect, androidx.compose.ui.graphics.Path.Direction): - Added method androidx.compose.ui.graphics.Path.addOval(androidx.compose.ui.geometry.Rect,androidx.compose.ui.graphics.Path.Direction) -AddedAbstractMethod: androidx.compose.ui.graphics.Path#addRect(androidx.compose.ui.geometry.Rect, androidx.compose.ui.graphics.Path.Direction): - Added method androidx.compose.ui.graphics.Path.addRect(androidx.compose.ui.geometry.Rect,androidx.compose.ui.graphics.Path.Direction) -AddedAbstractMethod: androidx.compose.ui.graphics.Path#addRoundRect(androidx.compose.ui.geometry.RoundRect, androidx.compose.ui.graphics.Path.Direction): - Added method androidx.compose.ui.graphics.Path.addRoundRect(androidx.compose.ui.geometry.RoundRect,androidx.compose.ui.graphics.Path.Direction) - - -RemovedMethod: androidx.compose.ui.graphics.PaintKt#Paint(): - Removed method androidx.compose.ui.graphics.PaintKt.Paint() +RemovedMethod: androidx.compose.ui.graphics.Color#getAlpha(): + Removed method androidx.compose.ui.graphics.Color.getAlpha() +RemovedMethod: androidx.compose.ui.graphics.Color#getBlue(): + Removed method androidx.compose.ui.graphics.Color.getBlue() +RemovedMethod: androidx.compose.ui.graphics.Color#getColorSpace(): + Removed method androidx.compose.ui.graphics.Color.getColorSpace() +RemovedMethod: androidx.compose.ui.graphics.Color#getGreen(): + Removed method androidx.compose.ui.graphics.Color.getGreen() +RemovedMethod: androidx.compose.ui.graphics.Color#getRed(): + Removed method androidx.compose.ui.graphics.Color.getRed() +RemovedMethod: androidx.compose.ui.graphics.colorspace.ColorModel#getComponentCount(): + Removed method androidx.compose.ui.graphics.colorspace.ColorModel.getComponentCount() diff --git a/compose/ui/ui-graphics/api/current.txt b/compose/ui/ui-graphics/api/current.txt index 6c2af78cf21d8..08de04cdb4928 100644 --- a/compose/ui/ui-graphics/api/current.txt +++ b/compose/ui/ui-graphics/api/current.txt @@ -332,11 +332,6 @@ package androidx.compose.ui.graphics { method @androidx.compose.runtime.Stable public inline operator androidx.compose.ui.graphics.colorspace.ColorSpace component5(); method public long convert(androidx.compose.ui.graphics.colorspace.ColorSpace colorSpace); method @androidx.compose.runtime.Stable public long copy(optional float alpha, optional float red, optional float green, optional float blue); - method public float getAlpha(); - method public float getBlue(); - method public androidx.compose.ui.graphics.colorspace.ColorSpace getColorSpace(); - method public float getGreen(); - method public float getRed(); method public long getValue(); property @androidx.compose.runtime.Stable public final float alpha; property @androidx.compose.runtime.Stable public final float blue; @@ -363,19 +358,19 @@ package androidx.compose.ui.graphics { method public long getYellow(); method public long hsl(float hue, float saturation, float lightness, optional float alpha, optional androidx.compose.ui.graphics.colorspace.Rgb colorSpace); method public long hsv(float hue, float saturation, float value, optional float alpha, optional androidx.compose.ui.graphics.colorspace.Rgb colorSpace); - property public final long Black; - property public final long Blue; - property public final long Cyan; - property public final long DarkGray; - property public final long Gray; - property public final long Green; - property public final long LightGray; - property public final long Magenta; - property public final long Red; - property public final long Transparent; - property public final long Unspecified; - property public final long White; - property public final long Yellow; + property @androidx.compose.runtime.Stable public final long Black; + property @androidx.compose.runtime.Stable public final long Blue; + property @androidx.compose.runtime.Stable public final long Cyan; + property @androidx.compose.runtime.Stable public final long DarkGray; + property @androidx.compose.runtime.Stable public final long Gray; + property @androidx.compose.runtime.Stable public final long Green; + property @androidx.compose.runtime.Stable public final long LightGray; + property @androidx.compose.runtime.Stable public final long Magenta; + property @androidx.compose.runtime.Stable public final long Red; + property @androidx.compose.runtime.Stable public final long Transparent; + property @androidx.compose.runtime.Stable public final long Unspecified; + property @androidx.compose.runtime.Stable public final long White; + property @androidx.compose.runtime.Stable public final long Yellow; } @androidx.compose.runtime.Immutable public class ColorFilter { @@ -403,7 +398,6 @@ package androidx.compose.ui.graphics { } @kotlin.jvm.JvmInline public final value class ColorMatrix { - ctor public ColorMatrix(); ctor public ColorMatrix(optional float[] values); method public void convertRgbToYuv(); method public void convertYuvToRgb(); @@ -513,7 +507,6 @@ package androidx.compose.ui.graphics { } @kotlin.jvm.JvmInline public final value class Matrix { - ctor public Matrix(); ctor public Matrix(optional float[] values); method public inline operator float get(int row, int column); method public float[] getValues(); @@ -547,6 +540,17 @@ package androidx.compose.ui.graphics { } public static final class Matrix.Companion { + property public static final int Perspective0; + property public static final int Perspective1; + property public static final int Perspective2; + property public static final int ScaleX; + property public static final int ScaleY; + property public static final int ScaleZ; + property public static final int SkewX; + property public static final int SkewY; + property public static final int TranslateX; + property public static final int TranslateY; + property public static final int TranslateZ; } public final class MatrixKt { @@ -638,6 +642,7 @@ package androidx.compose.ui.graphics { } public final class PaintKt { + property public static final float DefaultAlpha; field public static final float DefaultAlpha = 1.0f; } @@ -875,7 +880,7 @@ package androidx.compose.ui.graphics { public final class RectangleShapeKt { method public static androidx.compose.ui.graphics.Shape getRectangleShape(); - property public static final androidx.compose.ui.graphics.Shape RectangleShape; + property @androidx.compose.runtime.Stable public static final androidx.compose.ui.graphics.Shape RectangleShape; } @androidx.compose.runtime.Immutable public abstract sealed class RenderEffect { @@ -909,15 +914,15 @@ package androidx.compose.ui.graphics { method public float getBlurRadius(); method public long getColor(); method public long getOffset(); - property public final float blurRadius; - property public final long color; - property public final long offset; + property @androidx.compose.runtime.Stable public final float blurRadius; + property @androidx.compose.runtime.Stable public final long color; + property @androidx.compose.runtime.Stable public final long offset; field public static final androidx.compose.ui.graphics.Shadow.Companion Companion; } public static final class Shadow.Companion { method public androidx.compose.ui.graphics.Shadow getNone(); - property public final androidx.compose.ui.graphics.Shadow None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.graphics.Shadow None; } public final class ShadowKt { @@ -1038,8 +1043,7 @@ package androidx.compose.ui.graphics.colorspace { } @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class ColorModel { - method @IntRange(from=1L, to=4L) public int getComponentCount(); - property @IntRange(from=1L, to=4L) @androidx.compose.runtime.Stable public final int componentCount; + property @androidx.compose.runtime.Stable public final int componentCount; field public static final androidx.compose.ui.graphics.colorspace.ColorModel.Companion Companion; } @@ -1412,6 +1416,8 @@ package androidx.compose.ui.graphics.drawscope { method public int getDefaultJoin(); property public final int DefaultCap; property public final int DefaultJoin; + property public static final float DefaultMiter; + property public static final float HairlineWidth; } } @@ -1513,6 +1519,7 @@ package androidx.compose.ui.graphics.layer { public final class GraphicsLayerKt { method public static void drawLayer(androidx.compose.ui.graphics.drawscope.DrawScope, androidx.compose.ui.graphics.layer.GraphicsLayer graphicsLayer); method public static void setOutline(androidx.compose.ui.graphics.layer.GraphicsLayer, androidx.compose.ui.graphics.Outline outline); + property public static final float DefaultCameraDistance; field public static final float DefaultCameraDistance = 8.0f; } diff --git a/compose/ui/ui-graphics/api/restricted_current.ignore b/compose/ui/ui-graphics/api/restricted_current.ignore index 90bda4dc80b8e..4fd18cac0409e 100644 --- a/compose/ui/ui-graphics/api/restricted_current.ignore +++ b/compose/ui/ui-graphics/api/restricted_current.ignore @@ -1,11 +1,13 @@ // Baseline format: 1.0 -AddedAbstractMethod: androidx.compose.ui.graphics.Path#addOval(androidx.compose.ui.geometry.Rect, androidx.compose.ui.graphics.Path.Direction): - Added method androidx.compose.ui.graphics.Path.addOval(androidx.compose.ui.geometry.Rect,androidx.compose.ui.graphics.Path.Direction) -AddedAbstractMethod: androidx.compose.ui.graphics.Path#addRect(androidx.compose.ui.geometry.Rect, androidx.compose.ui.graphics.Path.Direction): - Added method androidx.compose.ui.graphics.Path.addRect(androidx.compose.ui.geometry.Rect,androidx.compose.ui.graphics.Path.Direction) -AddedAbstractMethod: androidx.compose.ui.graphics.Path#addRoundRect(androidx.compose.ui.geometry.RoundRect, androidx.compose.ui.graphics.Path.Direction): - Added method androidx.compose.ui.graphics.Path.addRoundRect(androidx.compose.ui.geometry.RoundRect,androidx.compose.ui.graphics.Path.Direction) - - -RemovedMethod: androidx.compose.ui.graphics.PaintKt#Paint(): - Removed method androidx.compose.ui.graphics.PaintKt.Paint() +RemovedMethod: androidx.compose.ui.graphics.Color#getAlpha(): + Removed method androidx.compose.ui.graphics.Color.getAlpha() +RemovedMethod: androidx.compose.ui.graphics.Color#getBlue(): + Removed method androidx.compose.ui.graphics.Color.getBlue() +RemovedMethod: androidx.compose.ui.graphics.Color#getColorSpace(): + Removed method androidx.compose.ui.graphics.Color.getColorSpace() +RemovedMethod: androidx.compose.ui.graphics.Color#getGreen(): + Removed method androidx.compose.ui.graphics.Color.getGreen() +RemovedMethod: androidx.compose.ui.graphics.Color#getRed(): + Removed method androidx.compose.ui.graphics.Color.getRed() +RemovedMethod: androidx.compose.ui.graphics.colorspace.ColorModel#getComponentCount(): + Removed method androidx.compose.ui.graphics.colorspace.ColorModel.getComponentCount() diff --git a/compose/ui/ui-graphics/api/restricted_current.txt b/compose/ui/ui-graphics/api/restricted_current.txt index bcf64b011641c..06bc3339082bd 100644 --- a/compose/ui/ui-graphics/api/restricted_current.txt +++ b/compose/ui/ui-graphics/api/restricted_current.txt @@ -32,6 +32,7 @@ package androidx.compose.ui.graphics { method public void skew(float sx, float sy); method public android.graphics.Region.Op toRegionOp(int); method public void translate(float dx, float dy); + property @kotlin.PublishedApi internal final android.graphics.Canvas internalCanvas; field @kotlin.PublishedApi internal android.graphics.Canvas internalCanvas; } @@ -340,6 +341,7 @@ package androidx.compose.ui.graphics { public final class CanvasHolder { ctor public CanvasHolder(); method public inline void drawInto(android.graphics.Canvas targetCanvas, kotlin.jvm.functions.Function1 block); + property @kotlin.PublishedApi internal final androidx.compose.ui.graphics.AndroidCanvas androidCanvas; field @kotlin.PublishedApi internal final androidx.compose.ui.graphics.AndroidCanvas androidCanvas; } @@ -372,11 +374,6 @@ package androidx.compose.ui.graphics { method @androidx.compose.runtime.Stable public inline operator androidx.compose.ui.graphics.colorspace.ColorSpace component5(); method public long convert(androidx.compose.ui.graphics.colorspace.ColorSpace colorSpace); method @androidx.compose.runtime.Stable public long copy(optional float alpha, optional float red, optional float green, optional float blue); - method public float getAlpha(); - method public float getBlue(); - method public androidx.compose.ui.graphics.colorspace.ColorSpace getColorSpace(); - method public float getGreen(); - method public float getRed(); method public long getValue(); property @androidx.compose.runtime.Stable public final float alpha; property @androidx.compose.runtime.Stable public final float blue; @@ -403,19 +400,19 @@ package androidx.compose.ui.graphics { method public long getYellow(); method public long hsl(float hue, float saturation, float lightness, optional float alpha, optional androidx.compose.ui.graphics.colorspace.Rgb colorSpace); method public long hsv(float hue, float saturation, float value, optional float alpha, optional androidx.compose.ui.graphics.colorspace.Rgb colorSpace); - property public final long Black; - property public final long Blue; - property public final long Cyan; - property public final long DarkGray; - property public final long Gray; - property public final long Green; - property public final long LightGray; - property public final long Magenta; - property public final long Red; - property public final long Transparent; - property public final long Unspecified; - property public final long White; - property public final long Yellow; + property @androidx.compose.runtime.Stable public final long Black; + property @androidx.compose.runtime.Stable public final long Blue; + property @androidx.compose.runtime.Stable public final long Cyan; + property @androidx.compose.runtime.Stable public final long DarkGray; + property @androidx.compose.runtime.Stable public final long Gray; + property @androidx.compose.runtime.Stable public final long Green; + property @androidx.compose.runtime.Stable public final long LightGray; + property @androidx.compose.runtime.Stable public final long Magenta; + property @androidx.compose.runtime.Stable public final long Red; + property @androidx.compose.runtime.Stable public final long Transparent; + property @androidx.compose.runtime.Stable public final long Unspecified; + property @androidx.compose.runtime.Stable public final long White; + property @androidx.compose.runtime.Stable public final long Yellow; } @androidx.compose.runtime.Immutable public class ColorFilter { @@ -440,11 +437,11 @@ package androidx.compose.ui.graphics { method @androidx.compose.runtime.Stable public static float luminance(long); method public static inline long takeOrElse(long, kotlin.jvm.functions.Function0 block); method @ColorInt @androidx.compose.runtime.Stable public static int toArgb(long); + property @kotlin.PublishedApi internal static final long UnspecifiedColor; field @kotlin.PublishedApi internal static final long UnspecifiedColor = 16L; // 0x10L } @kotlin.jvm.JvmInline public final value class ColorMatrix { - ctor public ColorMatrix(); ctor public ColorMatrix(optional float[] values); method public void convertRgbToYuv(); method public void convertYuvToRgb(); @@ -585,7 +582,6 @@ package androidx.compose.ui.graphics { } @kotlin.jvm.JvmInline public final value class Matrix { - ctor public Matrix(); ctor public Matrix(optional float[] values); method public inline operator float get(int row, int column); method public float[] getValues(); @@ -619,6 +615,17 @@ package androidx.compose.ui.graphics { } public static final class Matrix.Companion { + property public static final int Perspective0; + property public static final int Perspective1; + property public static final int Perspective2; + property public static final int ScaleX; + property public static final int ScaleY; + property public static final int ScaleZ; + property public static final int SkewX; + property public static final int SkewY; + property public static final int TranslateX; + property public static final int TranslateY; + property public static final int TranslateZ; } public final class MatrixKt { @@ -710,6 +717,7 @@ package androidx.compose.ui.graphics { } public final class PaintKt { + property public static final float DefaultAlpha; field public static final float DefaultAlpha = 1.0f; } @@ -947,7 +955,7 @@ package androidx.compose.ui.graphics { public final class RectangleShapeKt { method public static androidx.compose.ui.graphics.Shape getRectangleShape(); - property public static final androidx.compose.ui.graphics.Shape RectangleShape; + property @androidx.compose.runtime.Stable public static final androidx.compose.ui.graphics.Shape RectangleShape; } @androidx.compose.runtime.Immutable public abstract sealed class RenderEffect { @@ -981,15 +989,15 @@ package androidx.compose.ui.graphics { method public float getBlurRadius(); method public long getColor(); method public long getOffset(); - property public final float blurRadius; - property public final long color; - property public final long offset; + property @androidx.compose.runtime.Stable public final float blurRadius; + property @androidx.compose.runtime.Stable public final long color; + property @androidx.compose.runtime.Stable public final long offset; field public static final androidx.compose.ui.graphics.Shadow.Companion Companion; } public static final class Shadow.Companion { method public androidx.compose.ui.graphics.Shadow getNone(); - property public final androidx.compose.ui.graphics.Shadow None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.graphics.Shadow None; } public final class ShadowKt { @@ -1110,8 +1118,7 @@ package androidx.compose.ui.graphics.colorspace { } @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class ColorModel { - method @IntRange(from=1L, to=4L) public int getComponentCount(); - property @IntRange(from=1L, to=4L) @androidx.compose.runtime.Stable public final int componentCount; + property @androidx.compose.runtime.Stable public final int componentCount; field public static final androidx.compose.ui.graphics.colorspace.ColorModel.Companion Companion; } @@ -1346,6 +1353,7 @@ package androidx.compose.ui.graphics.drawscope { method public androidx.compose.ui.unit.LayoutDirection getLayoutDirection(); property public float density; property public androidx.compose.ui.graphics.drawscope.DrawContext drawContext; + property @kotlin.PublishedApi internal final androidx.compose.ui.graphics.drawscope.CanvasDrawScope.DrawParams drawParams; property public float fontScale; property public androidx.compose.ui.unit.LayoutDirection layoutDirection; field @kotlin.PublishedApi internal final androidx.compose.ui.graphics.drawscope.CanvasDrawScope.DrawParams drawParams; @@ -1509,6 +1517,8 @@ package androidx.compose.ui.graphics.drawscope { method public int getDefaultJoin(); property public final int DefaultCap; property public final int DefaultJoin; + property public static final float DefaultMiter; + property public static final float HairlineWidth; } } @@ -1610,6 +1620,7 @@ package androidx.compose.ui.graphics.layer { public final class GraphicsLayerKt { method public static void drawLayer(androidx.compose.ui.graphics.drawscope.DrawScope, androidx.compose.ui.graphics.layer.GraphicsLayer graphicsLayer); method public static void setOutline(androidx.compose.ui.graphics.layer.GraphicsLayer, androidx.compose.ui.graphics.Outline outline); + property public static final float DefaultCameraDistance; field public static final float DefaultCameraDistance = 8.0f; } diff --git a/compose/ui/ui-graphics/proguard-rules.pro b/compose/ui/ui-graphics/proguard-rules.pro new file mode 100644 index 0000000000000..67d118b98fa5e --- /dev/null +++ b/compose/ui/ui-graphics/proguard-rules.pro @@ -0,0 +1,24 @@ +# Copyright (C) 2020 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. + +# Keep all the functions created to throw an exception. We don't want these functions to be +# inlined in any way, which R8 will do by default. The whole point of these functions is to +# reduce the amount of code generated at the call site. +-keep,allowshrinking,allowobfuscation class androidx.compose.**.* { + static void throw*Exception(...); + static void throw*ExceptionForNullCheck(...); + # For methods returning Nothing + static java.lang.Void throw*Exception(...); + static java.lang.Void throw*ExceptionForNullCheck(...); +} diff --git a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt index 57085cf457c31..658156be7c2c1 100644 --- a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt +++ b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.graphics.layer +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.PixelFormat @@ -43,6 +44,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PixelMap import androidx.compose.ui.graphics.TestActivity import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.drawIntoCanvas @@ -149,7 +151,8 @@ class AndroidGraphicsLayerTest { .toImageBitmap() .toPixelMap() .verifyQuadrants(Color.Red, Color.Red, Color.Red, Color.Red) - } + }, + verifySoftwareRender = false // Only supported in hardware accelerated use cases ) } @@ -216,9 +219,6 @@ class AndroidGraphicsLayerTest { ) } - // this test is failing on API 21 as there toImageBitmap() is using software rendering - // and we reverted the software rendering b/333866398 - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.LOLLIPOP_MR1) @Test fun testPersistenceDrawAfterHwuiDiscardsDisplaylists() { // Layer persistence calls should not fail even if the DisplayList is discarded beforehand @@ -235,7 +235,8 @@ class AndroidGraphicsLayerTest { } drawIntoCanvas { layer.drawForPersistence(it) } }, - verify = { it.verifyQuadrants(Color.Red, Color.Red, Color.Red, Color.Red) } + verify = { it.verifyQuadrants(Color.Red, Color.Red, Color.Red, Color.Red) }, + verifySoftwareRender = false ) } @@ -763,7 +764,8 @@ class AndroidGraphicsLayerTest { } assertTrue(shadowPixelCount > 0) }, - usePixelCopy = true + usePixelCopy = true, + verifySoftwareRender = false // Elevation only supported with hardware acceleration ) } @@ -840,6 +842,7 @@ class AndroidGraphicsLayerTest { } }, usePixelCopy = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O, + verifySoftwareRender = false // Elevation only supported with hardware acceleration ) } @@ -897,7 +900,8 @@ class AndroidGraphicsLayerTest { } Assert.assertTrue(shadowPixelCount > 0) }, - usePixelCopy = true + usePixelCopy = true, + verifySoftwareRender = false // Elevation only supported with hardware acceleration ) } @@ -1151,7 +1155,8 @@ class AndroidGraphicsLayerTest { ) } }, - usePixelCopy = true + usePixelCopy = true, + verifySoftwareRender = false // Elevation only supported with hardware acceleration ) } @@ -1187,7 +1192,8 @@ class AndroidGraphicsLayerTest { } assertTrue(nonPureRedCount > 0) }, - entireScene = false + entireScene = false, + verifySoftwareRender = false // RenderEffect only supported with hardware acceleration ) } @@ -1300,7 +1306,8 @@ class AndroidGraphicsLayerTest { assertPixelColor(Color.Black, 0, height - 1) assertPixelColor(expectedCenter, width / 2, height / 2) } - } + }, + verifySoftwareRender = false // ModulateAlpha only supported with hardware acceleration ) } @@ -1692,6 +1699,76 @@ class AndroidGraphicsLayerTest { ) } + @Test + fun testCanvasTransformStateRestore() { + val bg = Color.White + val layerColor1 = Color.Red + val layerColor2 = Color.Green + val layerColor3 = Color.Blue + val layerColor4 = Color.Black + var layerSize = IntSize.Zero + graphicsLayerTest( + block = { graphicsContext -> + val layerWidth = size.width / 4 + val layerHeight = size.height / 4 + layerSize = IntSize(layerWidth.toInt(), layerHeight.toInt()) + val layer1 = + graphicsContext.createGraphicsLayer().apply { + record(size = layerSize) { drawRect(layerColor1) } + } + val layer2 = + graphicsContext.createGraphicsLayer().apply { + topLeft = IntOffset(layerWidth.toInt(), layerHeight.toInt()) + record(size = layerSize) { drawRect(layerColor2) } + } + val layer3 = + graphicsContext.createGraphicsLayer().apply { + topLeft = IntOffset((layerWidth * 2).toInt(), (layerHeight * 2).toInt()) + record(size = layerSize) { drawRect(layerColor3) } + } + val layer4 = + graphicsContext.createGraphicsLayer().apply { + record(size = layerSize) { drawRect(layerColor4) } + } + drawRect(bg) + translate(layerWidth / 2, layerHeight / 2) { + translate(layerWidth / 2, layerHeight / 2) { + drawLayer(layer1) + translate(layerWidth / 2, layerHeight / 2) { drawLayer(layer2) } + drawLayer(layer3) + } + } + + drawLayer(layer4) + }, + verify = { + val row1centerX = layerSize.width + layerSize.width / 2 + val row1centerY = layerSize.height + layerSize.height / 2 + + val row2centerX = layerSize.width + row1centerX + val row2centerY = layerSize.height + row1centerY + + val row3centerX = layerSize.width + row2centerX + val row3centerY = layerSize.height + row2centerY + + val row4centerX = layerSize.width + row3centerX + + it.assertPixelColor(layerColor1, row1centerX, row1centerY) + it.assertPixelColor(bg, row2centerX, row1centerY) + + it.assertPixelColor(bg, row1centerX, row2centerY) + it.assertPixelColor(layerColor2, row2centerX, row2centerY) + it.assertPixelColor(bg, row3centerX, row2centerY) + + it.assertPixelColor(bg, row2centerX, row3centerY) + it.assertPixelColor(layerColor3, row3centerX, row3centerY) + it.assertPixelColor(bg, row4centerX, row3centerY) + + it.assertPixelColor(layerColor4, layerSize.width / 2, layerSize.height / 2) + } + ) + } + private fun PixelMap.verifyQuadrants( topLeft: Color, topRight: Color, @@ -1712,7 +1789,8 @@ class AndroidGraphicsLayerTest { block: DrawScope.(GraphicsContext) -> Unit, verify: (suspend (PixelMap) -> Unit)? = null, entireScene: Boolean = false, - usePixelCopy: Boolean = false + usePixelCopy: Boolean = false, + verifySoftwareRender: Boolean = true ) { var scenario: ActivityScenario? = null var androidGraphicsContext: GraphicsContext? = null @@ -1808,6 +1886,16 @@ class AndroidGraphicsLayerTest { bitmap.toPixelMap() } runBlocking { verify(pixelMap) } + if (verifySoftwareRender) { + val softwareRenderLatch = CountDownLatch(1) + var softwareBitmap: Bitmap? = null + testActivity!!.runOnUiThread { + softwareBitmap = doSoftwareRender(target) + softwareRenderLatch.countDown() + } + assertTrue(softwareRenderLatch.await(300, TimeUnit.MILLISECONDS)) + runBlocking { verify(softwareBitmap!!.asImageBitmap().toPixelMap()) } + } } } finally { val detachLatch = CountDownLatch(1) @@ -1871,6 +1959,13 @@ class AndroidGraphicsLayerTest { ) } + private fun doSoftwareRender(target: View): Bitmap { + val bitmap = Bitmap.createBitmap(target.width, target.height, Bitmap.Config.ARGB_8888) + val softwareCanvas = Canvas(bitmap) + target.draw(softwareCanvas) + return bitmap + } + private class GraphicsContextHostDrawable( val graphicsContext: GraphicsContext, val block: DrawScope.(GraphicsContext) -> Unit diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt index 1a47be57dd77e..b9b0424e3785f 100644 --- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt +++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.RenderEffect import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.graphics.drawscope.DefaultDensity import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipPath @@ -82,6 +83,7 @@ internal constructor( private var outlinePath: Path? = null private var roundRectClipPath: Path? = null private var usePathForClip = false + private var softwareDrawScope: CanvasDrawScope? = null // Paint used only in Software rendering scenarios for API 21 when rendering to a Bitmap private var softwareLayerPaint: Paint? = null @@ -476,7 +478,7 @@ internal constructor( } internal fun drawForPersistence(canvas: Canvas) { - if (canvas.nativeCanvas.isHardwareAccelerated) { + if (canvas.nativeCanvas.isHardwareAccelerated || impl.supportsSoftwareRendering) { recreateDisplayListIfNeeded() impl.draw(canvas) } @@ -519,7 +521,6 @@ internal constructor( val androidCanvas = canvas.nativeCanvas val softwareRendered = !androidCanvas.isHardwareAccelerated if (softwareRendered) { - androidCanvas.save() transformCanvas(androidCanvas) } @@ -545,7 +546,13 @@ internal constructor( parentLayer?.addSubLayer(this) - impl.draw(canvas) + if (canvas.nativeCanvas.isHardwareAccelerated || impl.supportsSoftwareRendering) { + impl.draw(canvas) + } else { + val drawScope = softwareDrawScope ?: CanvasDrawScope().also { softwareDrawScope = it } + drawScope.draw(density, layoutDirection, canvas, size.toSize(), drawBlock) + } + if (willClipPath) { canvas.restore() } @@ -961,6 +968,14 @@ internal interface GraphicsLayerImpl { */ fun setOutline(outline: AndroidOutline?, outlineSize: IntSize) + /** + * Flag to determine if the layer implementation has a software backed implementation On Android + * L we conditionally also record drawing commands into a Picture as it does not natively + * support rendering into a Bitmap with hardware acceleration + */ + val supportsSoftwareRendering: Boolean + get() = false + /** Draw the GraphicsLayer into the provided canvas */ fun draw(canvas: Canvas) diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/GraphicsViewLayer.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/GraphicsViewLayer.android.kt index ed8c21730f1c6..9ed246d5655de 100644 --- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/GraphicsViewLayer.android.kt +++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/GraphicsViewLayer.android.kt @@ -446,6 +446,8 @@ internal class GraphicsViewLayer( } } + override val supportsSoftwareRendering: Boolean = mayRenderInSoftware + private fun recordDrawingOperations() { try { canvasHolder.drawInto(PlaceholderCanvas) { diff --git a/compose/ui/ui-graphics/src/androidUnitTest/kotlin/androidx/compose/ui/graphics/layer/RobolectricGraphicsLayerTest.kt b/compose/ui/ui-graphics/src/androidUnitTest/kotlin/androidx/compose/ui/graphics/layer/RobolectricGraphicsLayerTest.kt index 087a18c64717a..b725979d2ddce 100644 --- a/compose/ui/ui-graphics/src/androidUnitTest/kotlin/androidx/compose/ui/graphics/layer/RobolectricGraphicsLayerTest.kt +++ b/compose/ui/ui-graphics/src/androidUnitTest/kotlin/androidx/compose/ui/graphics/layer/RobolectricGraphicsLayerTest.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.inset +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.unit.Density @@ -65,7 +66,6 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -83,7 +83,6 @@ class RobolectricGraphicsLayerTest { val TEST_SIZE = IntSize(TEST_WIDTH, TEST_HEIGHT) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testGraphicsLayerBitmap() { lateinit var layer: GraphicsLayer @@ -121,7 +120,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testDrawLayer() { var layer: GraphicsLayer? = null @@ -142,7 +140,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testDrawAfterDiscard() { var layer: GraphicsLayer? = null @@ -164,7 +161,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testPersistenceDrawAfterHwuiDiscardsDisplaylists() { // Layer persistence calls should not fail even if the DisplayList is discarded beforehand @@ -182,7 +178,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRecordLayerWithSize() { graphicsLayerTest( @@ -197,7 +192,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRecordLayerWithOffset() { var layer: GraphicsLayer? = null @@ -220,7 +214,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testSetOffset() { var layer: GraphicsLayer? = null @@ -246,7 +239,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testSetAlpha() { var layer: GraphicsLayer? = null @@ -275,7 +267,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testSetScaleX() { var layer: GraphicsLayer? = null @@ -304,7 +295,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testSetScaleY() { var layer: GraphicsLayer? = null @@ -333,7 +323,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testDefaultPivot() { var layer: GraphicsLayer? = null @@ -359,7 +348,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testBottomRightPivot() { var layer: GraphicsLayer? = null @@ -384,7 +372,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testTranslationX() { var layer: GraphicsLayer? = null @@ -407,7 +394,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRectOutlineWithNonZeroTopLeft() { graphicsLayerTest( @@ -429,7 +415,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRoundRectOutlineWithNonZeroTopLeft() { graphicsLayerTest( @@ -451,7 +436,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRecordOverwritesPreviousRecord() { graphicsLayerTest( @@ -465,7 +449,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testTranslationY() { var layer: GraphicsLayer? = null @@ -488,7 +471,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRotationX() { var layer: GraphicsLayer? = null @@ -517,7 +499,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRotationY() { var layer: GraphicsLayer? = null @@ -545,7 +526,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRotationZ() { var layer: GraphicsLayer? = null @@ -600,7 +580,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testUnboundedClip() { var layer: GraphicsLayer? @@ -624,7 +603,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testBoundedClip() { var layer: GraphicsLayer? @@ -655,7 +633,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testCompositingStrategyAuto() { var layer: GraphicsLayer? @@ -695,7 +672,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testCompositingStrategyOffscreen() { var layer: GraphicsLayer? @@ -729,7 +705,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testCameraDistanceWithRotationY() { var layer: GraphicsLayer? @@ -758,7 +733,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testTintColorFilter() { var layer: GraphicsLayer? @@ -783,7 +757,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testBlendMode() { var layer: GraphicsLayer? @@ -835,7 +808,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRectOutlineClip() { var layer: GraphicsLayer? @@ -880,7 +852,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testPathOutlineClip() { var layer: GraphicsLayer? @@ -936,7 +907,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testRoundRectOutlineClip() { var layer: GraphicsLayer? @@ -991,7 +961,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun setOutlineExtensionAppliesValuesCorrectly() { graphicsLayerTest( @@ -1016,7 +985,6 @@ class RobolectricGraphicsLayerTest { ) } - @Ignore("Software rendering support was reverted. b/333866398") @Test fun testSwitchingFromClipToBoundsToClipToOutline() { val targetColor = Color.Red @@ -1056,6 +1024,76 @@ class RobolectricGraphicsLayerTest { ) } + @Test + fun testCanvasTransformStateRestore() { + val bg = Color.White + val layerColor1 = Color.Red + val layerColor2 = Color.Green + val layerColor3 = Color.Blue + val layerColor4 = Color.Black + var layerSize = IntSize.Zero + graphicsLayerTest( + block = { graphicsContext -> + val layerWidth = size.width / 4 + val layerHeight = size.height / 4 + layerSize = IntSize(layerWidth.toInt(), layerHeight.toInt()) + val layer1 = + graphicsContext.createGraphicsLayer().apply { + record(size = layerSize) { drawRect(layerColor1) } + } + val layer2 = + graphicsContext.createGraphicsLayer().apply { + topLeft = IntOffset(layerWidth.toInt(), layerHeight.toInt()) + record(size = layerSize) { drawRect(layerColor2) } + } + val layer3 = + graphicsContext.createGraphicsLayer().apply { + topLeft = IntOffset((layerWidth * 2).toInt(), (layerHeight * 2).toInt()) + record(size = layerSize) { drawRect(layerColor3) } + } + val layer4 = + graphicsContext.createGraphicsLayer().apply { + record(size = layerSize) { drawRect(layerColor4) } + } + drawRect(bg) + translate(layerWidth / 2, layerHeight / 2) { + translate(layerWidth / 2, layerHeight / 2) { + drawLayer(layer1) + translate(layerWidth / 2, layerHeight / 2) { drawLayer(layer2) } + drawLayer(layer3) + } + } + + drawLayer(layer4) + }, + verify = { + val row1centerX = layerSize.width + layerSize.width / 2 + val row1centerY = layerSize.height + layerSize.height / 2 + + val row2centerX = layerSize.width + row1centerX + val row2centerY = layerSize.height + row1centerY + + val row3centerX = layerSize.width + row2centerX + val row3centerY = layerSize.height + row2centerY + + val row4centerX = layerSize.width + row3centerX + + it.assertPixelColor(layerColor1, row1centerX, row1centerY) + it.assertPixelColor(bg, row2centerX, row1centerY) + + it.assertPixelColor(bg, row1centerX, row2centerY) + it.assertPixelColor(layerColor2, row2centerX, row2centerY) + it.assertPixelColor(bg, row3centerX, row2centerY) + + it.assertPixelColor(bg, row2centerX, row3centerY) + it.assertPixelColor(layerColor3, row3centerX, row3centerY) + it.assertPixelColor(bg, row4centerX, row3centerY) + + it.assertPixelColor(layerColor4, layerSize.width / 2, layerSize.height / 2) + } + ) + } + private fun PixelMap.verifyQuadrants( topLeft: Color, topRight: Color, diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Bezier.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Bezier.kt index 15437fd8868d1..fe448fb91cf4e 100644 --- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Bezier.kt +++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Bezier.kt @@ -40,7 +40,7 @@ private const val Epsilon = 1e-7 // and because we use a fast approximation of cbrt(). The epsilon we use here is // slightly larger than the max error of fastCbrt() in the -1f..1f range // (8.3446500e-7f) but smaller than 1.0f.ulp * 10. -private const val FloatEpsilon = 1e-6f +private const val FloatEpsilon = 1.05e-6f /** * Evaluate the specified [segment] at position [t] and returns the X coordinate of the segment's diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Float16.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Float16.kt index 24e2318774c0c..0914fbac8afe8 100644 --- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Float16.kt +++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Float16.kt @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:Suppress("KotlinRedundantDiagnosticSuppress") + package androidx.compose.ui.graphics import androidx.compose.ui.util.floatFromBits @@ -153,7 +155,7 @@ internal value class Float16(val halfValue: Short) : Comparable { */ fun toBits(): Int = if (isNaN()) { - NaN.halfValue.toInt() + Fp16TheNaN } else { halfValue.toInt() and 0xffff } @@ -206,13 +208,16 @@ internal value class Float16(val halfValue: Short) : Comparable { * * `NaN.sign` is `NaN` */ val sign: Float16 - get() = - when { - isNaN() -> NaN - this < NegativeZero -> NegativeOne - this > PositiveZero -> One - else -> this // this is zero, either positive or negative - } + get() { + val v = halfValue.toInt() and Fp16Combined + val u = + if ((v > Fp16ExponentMax) or (v == 0)) { // 0.0 or NaN + v + } else { + (halfValue.toInt() and Fp16SignMask) or Fp16One + } + return Float16(u.toShort()) + } /** Returns a [Float16] with the magnitude of this and the sign of [sign] */ fun withSign(sign: Float16): Float16 = @@ -394,8 +399,8 @@ internal value class Float16(val halfValue: Short) : Comparable { * @return True if the value is normalized, false otherwise */ fun isNormalized(): Boolean { - return halfValue.toInt() and Fp16ExponentMax != 0 && - halfValue.toInt() and Fp16ExponentMax != Fp16ExponentMax + val v = halfValue.toInt() and Fp16ExponentMax + return (v != 0) and (v != Fp16ExponentMax) } /** @@ -491,9 +496,6 @@ internal value class Float16(val halfValue: Short) : Comparable { } } -private val One = Float16(1f) -private val NegativeOne = Float16(-1f) - private const val Fp16SignShift = 15 private const val Fp16SignMask = 0x8000 private const val Fp16ExponentShift = 10 @@ -502,6 +504,8 @@ private const val Fp16SignificandMask = 0x3ff private const val Fp16ExponentBias = 15 private const val Fp16Combined = 0x7fff private const val Fp16ExponentMax = 0x7c00 +private const val Fp16One = 0x3c00 +private const val Fp16TheNaN = 0x7e00 private const val Fp32SignShift = 31 private const val Fp32ExponentShift = 23 diff --git a/compose/ui/ui-inspection/OWNERS b/compose/ui/ui-inspection/OWNERS index 5548ceb51006a..26f9799e2fc0c 100644 --- a/compose/ui/ui-inspection/OWNERS +++ b/compose/ui/ui-inspection/OWNERS @@ -1,4 +1,4 @@ # Bug component: 1333602 jbakermalone@google.com jlauridsen@google.com -sergeyv@google.com +yboyar@google.com diff --git a/compose/ui/ui-inspection/generate-packages/compose_packages_list.txt b/compose/ui/ui-inspection/generate-packages/compose_packages_list.txt index ce85fcaaa8255..01833f606cc6c 100644 --- a/compose/ui/ui-inspection/generate-packages/compose_packages_list.txt +++ b/compose/ui/ui-inspection/generate-packages/compose_packages_list.txt @@ -1,7 +1,6 @@ androidx.compose.animation androidx.compose.animation.core androidx.compose.animation.graphics.vector -androidx.compose.desktop androidx.compose.foundation androidx.compose.foundation.contextmenu androidx.compose.foundation.gestures @@ -16,7 +15,6 @@ androidx.compose.foundation.pager androidx.compose.foundation.text androidx.compose.foundation.text.input androidx.compose.foundation.text.selection -androidx.compose.foundation.window androidx.compose.material androidx.compose.material.internal androidx.compose.material.navigation @@ -41,23 +39,26 @@ androidx.compose.runtime.rxjava2 androidx.compose.runtime.rxjava3 androidx.compose.runtime.saveable androidx.compose.ui -androidx.compose.ui.awt -androidx.compose.ui.draw androidx.compose.ui.graphics androidx.compose.ui.graphics.benchmark androidx.compose.ui.graphics.vector androidx.compose.ui.layout androidx.compose.ui.platform +androidx.compose.ui.spatial androidx.compose.ui.text -androidx.compose.ui.util androidx.compose.ui.viewinterop androidx.compose.ui.window +androidx.lifecycle.compose androidx.navigation.compose androidx.wear.compose.foundation androidx.wear.compose.foundation.lazy +androidx.wear.compose.foundation.pager androidx.wear.compose.foundation.rotary androidx.wear.compose.material androidx.wear.compose.material.dialog androidx.wear.compose.material3 +androidx.wear.compose.material3.internal +androidx.wear.compose.material3.lazy +androidx.wear.compose.material3.macrobenchmark.common.baselineprofile androidx.wear.compose.materialcore androidx.wear.compose.navigation \ No newline at end of file diff --git a/compose/ui/ui-inspection/generate-packages/generate_compose_packages.py b/compose/ui/ui-inspection/generate-packages/generate_compose_packages.py index 79267c532dd61..7d4c962209ada 100755 --- a/compose/ui/ui-inspection/generate-packages/generate_compose_packages.py +++ b/compose/ui/ui-inspection/generate-packages/generate_compose_packages.py @@ -32,6 +32,7 @@ '../../..', '../../../../navigation/navigation-compose', '../../../../wear/compose', + '../../../../lifecycle/lifecycle-runtime-compose', ] # Reads a source file with the given file_path and adds its package to the current set of packages diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt new file mode 100644 index 0000000000000..5952212084087 --- /dev/null +++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt @@ -0,0 +1,161 @@ +/* + * 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. + */ + +package androidx.compose.ui.inspection + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Build +import android.view.SurfaceControlViewHost +import android.view.View +import androidx.annotation.RequiresApi +import androidx.inspection.InspectorEnvironment +import java.lang.reflect.Field +import java.lang.reflect.Method + +private const val PANEL_ENTITY_CLASS = "com.google.vr.androidx.xr.core.PanelEntity" +private const val PANEL_ENTITY_IMPL_CLASS = + "com.google.vr.realitycore.runtime.androidxr.PanelEntityImpl" +private const val JXR_CORE_SESSION_CLASS = "com.google.vr.androidx.xr.core.Session" + +private const val GET_ENTITIES_OF_TYPE_METHOD = "getEntitiesOfType" +private const val IS_HIDDEN_METHOD = "isHidden" + +private const val SURFACE_CONTROL_VIEW_HOST_FIELD = "surfaceControlViewHost" +private const val RT_PANEL_ENTITY_FIELD = "rtPanelEntity" + +class AlternateViewHelper(private val environment: InspectorEnvironment) { + private val activity = environment.artTooling().findInstances(Activity::class.java).first() + + fun getAlternateViews(): List { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) getExtraViewsImpl() else emptyList() + } catch (ex: Exception) { + emptyList() + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getExtraViewsImpl(): List { + val sessionClass = loadClass(JXR_CORE_SESSION_CLASS) + val xrSessions = environment.artTooling().findInstances(sessionClass) + return xrSessions.flatMap { getSessionViews(it) } + } + + @RequiresApi(Build.VERSION_CODES.R) + @SuppressLint("BanUncheckedReflection") + private fun getSessionViews(session: Any): List { + val entitiesFun = + loadMethod(session.javaClass, GET_ENTITIES_OF_TYPE_METHOD, Class::class.java) + val entityClass = loadClass(PANEL_ENTITY_CLASS) + val entities = entitiesFun.invoke(session, entityClass) as List<*> + return entities.mapNotNull { entity -> entity?.let { getView(it) } } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getView(entity: Any): View? { + if (isHidden(entity)) { + return null + } + return entity + .mapAllFields { field -> + if (field.name == RT_PANEL_ENTITY_FIELD) { + getRuntimeEntityView(field.get(entity)!!) + } else { + null + } + } + .filterNotNull() + .firstOrNull() + } + + @SuppressLint("BanUncheckedReflection") + private fun isHidden(instance: Any): Boolean { + var isHidden = false + + runCatching { + instance.mapAllMethods { method -> + if (method.name == IS_HIDDEN_METHOD) { + isHidden = method.invoke(instance, true) as Boolean + return@mapAllMethods + } + } + } + + return isHidden + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getRuntimeEntityView(instance: Any): View? { + val clazz = instance.javaClass + if (clazz.name != PANEL_ENTITY_IMPL_CLASS) { + return null + } + val surfaceControlViewHostField = loadField(clazz, SURFACE_CONTROL_VIEW_HOST_FIELD) + if (surfaceControlViewHostField != null) { + surfaceControlViewHostField.isAccessible = true + val surfaceControlViewHost = + surfaceControlViewHostField.get(instance) as SurfaceControlViewHost + return surfaceControlViewHost.view + } else { + return null + } + } + + @Suppress("UnnecessaryLambdaCreation") + private fun Any.mapAllFields(block: (filed: Field) -> T): List { + var clazz: Class<*>? = javaClass + val results = mutableListOf() + + while (clazz != Any::class.java && clazz != null) { + results.addAll(loadFields(clazz).map { block(it) }) + + // Move to the superclass + clazz = clazz.superclass + } + + return results + } + + @Suppress("UnnecessaryLambdaCreation") + private fun Any.mapAllMethods(block: (method: Method) -> T): List { + var clazz: Class<*>? = javaClass + val results = mutableListOf() + + while (clazz != Any::class.java && clazz != null) { + results.addAll(loadMethods(clazz).map { block(it) }) + + // Move to the superclass + clazz = clazz.superclass + } + + return results + } + + private fun loadClass(name: String): Class<*> = activity.classLoader.loadClass(name) + + private fun loadMethod(cls: Class<*>, name: String, vararg args: Class<*>): Method = + cls.getDeclaredMethod(name, *args).apply { isAccessible = true } + + private fun loadMethods(cls: Class<*>): List = + cls.declaredMethods.map { it.apply { isAccessible = true } } + + private fun loadField(cls: Class<*>, name: String): Field? = + runCatching { cls.getDeclaredField(name).apply { isAccessible = true } }.getOrNull() + + private fun loadFields(cls: Class<*>): List = + cls.declaredFields.map { it.apply { it.isAccessible = true } } +} diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt index e5a9a658eed08..9364f6ef15173 100644 --- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt +++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt @@ -18,7 +18,6 @@ package androidx.compose.ui.inspection import android.util.Log import android.view.View -import android.view.inspector.WindowInspector import androidx.collection.LongList import androidx.collection.LongObjectMap import androidx.collection.MutableLongObjectMap @@ -116,6 +115,7 @@ class ComposeLayoutInspector(connection: Connection, environment: InspectorEnvir val viewsToSkip: LongList ) + private val rootsDetector = RootsDetector(environment) private val layoutInspectorTree = LayoutInspectorTree() private val recompositionHandler = RecompositionHandler(environment.artTooling()) private var delayParameterExtractions = false @@ -472,7 +472,7 @@ class ComposeLayoutInspector(connection: Connection, environment: InspectorEnvir ThreadUtils.assertOnMainThread() val roots = - WindowInspector.getGlobalWindowViews().asSequence().filter { root -> + rootsDetector.getRoots().asSequence().filter { root -> root.visibility == View.VISIBLE && root.isAttachedToWindow && (generation > 0 || root.uniqueDrawingId == rootViewId) @@ -516,7 +516,7 @@ class ComposeLayoutInspector(connection: Connection, environment: InspectorEnvir /** Add a slot table to all AndroidComposeViews that doesn't already have one. */ private fun addSlotTableToComposeViews() = ThreadUtils.runOnMainThread { - val roots = WindowInspector.getGlobalWindowViews() + val roots = rootsDetector.getRoots() val composeViews = roots.flatMap { it.flatten() }.filter { it.isAndroidComposeView() } @@ -524,8 +524,7 @@ class ComposeLayoutInspector(connection: Connection, environment: InspectorEnvir val slotTablesAdded = composeViews.sumOf { it.addSlotTable() } if (slotTablesAdded > 0) { // The slot tables added to existing views will be empty until the - // composables - // are reloaded. Do that now: + // composables are reloaded. Do that now: hotReload() } } diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt new file mode 100644 index 0000000000000..5619d3cae8ce2 --- /dev/null +++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt @@ -0,0 +1,37 @@ +/* + * 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. + */ + +package androidx.compose.ui.inspection + +import android.view.View +import android.view.inspector.WindowInspector +import androidx.compose.ui.inspection.util.ThreadUtils +import androidx.inspection.InspectorEnvironment + +class RootsDetector(environment: InspectorEnvironment) { + private val alternateViewHelper = AlternateViewHelper(environment) + + fun getRoots(): List { + val alternateViews = alternateViewHelper.getAlternateViews() + return alternateViews.ifEmpty { getAndroidViews() } + } + + private fun getAndroidViews(): List { + ThreadUtils.assertOnMainThread() + val views = WindowInspector.getGlobalWindowViews() + return views + } +} diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt index 82f3110fe7b24..8214bcb97a868 100644 --- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt +++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.layout.LayoutInfo import androidx.compose.ui.node.InteroperableComposeUiNode import androidx.compose.ui.node.Ref import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewRootForInspector import androidx.compose.ui.semantics.getAllSemanticsNodes import androidx.compose.ui.tooling.data.ContextCache @@ -774,6 +776,12 @@ class LayoutInspectorTree { group.data.filterIsInstance().singleOrNull()?.let { return it } + group.data.filterIsInstance().singleOrNull()?.let { + return object : ViewRootForInspector { + override val subCompositionView: AbstractComposeView + get() = it + } + } val refs = group.data.filterIsInstance>().map { it.value } return refs.filterIsInstance().singleOrNull() } @@ -818,13 +826,33 @@ class LayoutInspectorTree { * If a root is not found for this [owner] or if the stitching fails just return [nodes]. */ fun addRoot(owner: View, nodes: List): List { - val root = found[owner.uniqueDrawingId] ?: return nodes + val root = found[owner.uniqueDrawingId]?.let { skipEmptyRoot(it) } ?: return nodes val box = IntRect(0, 0, owner.width, owner.height) val info = StitchInfo(nodes, box) val result = listOf(stitch(root, info)) return if (info.added) result else nodes } + /** + * The root of a sub-composition often has a root with an empty name. If the root has a + * single child: skip the empty node. Otherwise: select an arbitrary child node name and use + * for the root (prefer functions with an uppercase name). + */ + private fun skipEmptyRoot(node: InspectorNode): InspectorNode = + when { + node.name.isNotEmpty() -> node + node.children.isEmpty() -> node // This should not happen + node.children.size == 1 -> node.children.single() + else -> { + val newNode = newNode() + newNode.shallowCopy(node) + val firstUpperCaseNamedChild = + node.children.firstOrNull { it.name.firstOrNull()?.isUpperCase() ?: false } + newNode.name = firstUpperCaseNamedChild?.name ?: node.children.first().name + buildAndRelease(newNode) + } + } + private fun stitch(node: InspectorNode, info: StitchInfo): InspectorNode { val children = node.children.map { stitch(it, info) } val index = children.indexOfFirst { it.id == PLACEHOLDER_ID } diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/PackageHashes.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/PackageHashes.kt index ab779517d1c64..bc1817c402a41 100644 --- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/PackageHashes.kt +++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/PackageHashes.kt @@ -16,7 +16,6 @@ val systemPackages = packageNameHash("androidx.compose.animation"), packageNameHash("androidx.compose.animation.core"), packageNameHash("androidx.compose.animation.graphics.vector"), - packageNameHash("androidx.compose.desktop"), packageNameHash("androidx.compose.foundation"), packageNameHash("androidx.compose.foundation.contextmenu"), packageNameHash("androidx.compose.foundation.gestures"), @@ -31,7 +30,6 @@ val systemPackages = packageNameHash("androidx.compose.foundation.text"), packageNameHash("androidx.compose.foundation.text.input"), packageNameHash("androidx.compose.foundation.text.selection"), - packageNameHash("androidx.compose.foundation.window"), packageNameHash("androidx.compose.material"), packageNameHash("androidx.compose.material.internal"), packageNameHash("androidx.compose.material.navigation"), @@ -56,24 +54,33 @@ val systemPackages = packageNameHash("androidx.compose.runtime.rxjava3"), packageNameHash("androidx.compose.runtime.saveable"), packageNameHash("androidx.compose.ui"), - packageNameHash("androidx.compose.ui.awt"), - packageNameHash("androidx.compose.ui.draw"), packageNameHash("androidx.compose.ui.graphics"), packageNameHash("androidx.compose.ui.graphics.benchmark"), packageNameHash("androidx.compose.ui.graphics.vector"), packageNameHash("androidx.compose.ui.layout"), packageNameHash("androidx.compose.ui.platform"), + packageNameHash("androidx.compose.ui.spatial"), packageNameHash("androidx.compose.ui.text"), - packageNameHash("androidx.compose.ui.util"), packageNameHash("androidx.compose.ui.viewinterop"), packageNameHash("androidx.compose.ui.window"), + packageNameHash("androidx.lifecycle.compose"), packageNameHash("androidx.navigation.compose"), packageNameHash("androidx.wear.compose.foundation"), packageNameHash("androidx.wear.compose.foundation.lazy"), + packageNameHash("androidx.wear.compose.foundation.pager"), packageNameHash("androidx.wear.compose.foundation.rotary"), packageNameHash("androidx.wear.compose.material"), packageNameHash("androidx.wear.compose.material.dialog"), packageNameHash("androidx.wear.compose.material3"), + packageNameHash("androidx.wear.compose.material3.internal"), + packageNameHash("androidx.wear.compose.material3.lazy"), + packageNameHash("androidx.wear.compose.material3.macrobenchmark.common.baselineprofile"), packageNameHash("androidx.wear.compose.materialcore"), packageNameHash("androidx.wear.compose.navigation"), + 1540251825, // "c.g.v.a.x.c.s" + 1937475945, // "c.g.v.a.x.c.u" + 398286671, // "c.g.v.a.x.c.u.l" + 12920985, // "c.g.v.a.x.c.u.n" + 627615912, // "c.g.v.a.x.c.u.p" + 268564053, // "c.g.v.a.x.c.u.s" ) diff --git a/compose/ui/ui-lint/src/test/java/androidx/compose/ui/lint/SuspiciousCompositionLocalModifierReadDetectorTest.kt b/compose/ui/ui-lint/src/test/java/androidx/compose/ui/lint/SuspiciousCompositionLocalModifierReadDetectorTest.kt index f4c3192bcdad1..3b1f2d1c779d9 100644 --- a/compose/ui/ui-lint/src/test/java/androidx/compose/ui/lint/SuspiciousCompositionLocalModifierReadDetectorTest.kt +++ b/compose/ui/ui-lint/src/test/java/androidx/compose/ui/lint/SuspiciousCompositionLocalModifierReadDetectorTest.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.lint +import androidx.compose.lint.test.Stubs import androidx.compose.ui.lint.SuspiciousCompositionLocalModifierReadDetector.Companion.SuspiciousCompositionLocalModifierRead import com.android.tools.lint.checks.infrastructure.LintDetectorTest import com.android.tools.lint.detector.api.Detector @@ -64,29 +65,6 @@ class SuspiciousCompositionLocalModifierReadDetectorTest : LintDetectorTest() { """ ) - private val CompositionLocalStub = - kotlin( - """ - package androidx.compose.runtime - - import java.lang.RuntimeException - - class CompositionLocal(defaultFactory: () -> T) - - class ProvidedValue internal constructor( - val compositionLocal: CompositionLocal, - val value: T, - val canOverride: Boolean - ) - - fun compositionLocalOf(defaultFactory: () -> T): CompositionLocal = - throw RuntimeException("Not implemented in lint stubs.") - - fun staticCompositionLocalOf(defaultFactory: () -> T): CompositionLocal = - throw RuntimeException("Not implemented in lint stubs.") - """ - ) - @Test fun testCompositionLocalReadInModifierAttachAndDetach() { lint() @@ -116,7 +94,7 @@ class SuspiciousCompositionLocalModifierReadDetectorTest : LintDetectorTest() { } """ ), - CompositionLocalStub, + Stubs.CompositionLocal, CompositionLocalConsumerModifierStub, ModifierNodeStub ) @@ -167,7 +145,7 @@ src/test/NodeUnderTest.kt:20: Error: Reading staticLocalInt in onDetach will onl } """ ), - CompositionLocalStub, + Stubs.CompositionLocal, CompositionLocalConsumerModifierStub, ModifierNodeStub ) @@ -201,7 +179,7 @@ src/test/NodeUnderTest.kt:20: Error: Reading staticLocalInt in onDetach will onl } """ ), - CompositionLocalStub, + Stubs.CompositionLocal, CompositionLocalConsumerModifierStub, ModifierNodeStub ) @@ -243,7 +221,7 @@ src/test/NodeUnderTest.kt:17: Error: CompositionLocals cannot be read in modifie } """ ), - CompositionLocalStub, + Stubs.CompositionLocal, CompositionLocalConsumerModifierStub, ModifierNodeStub ) @@ -275,7 +253,7 @@ src/test/NodeUnderTest.kt:17: Error: CompositionLocals cannot be read in modifie } """ ), - CompositionLocalStub, + Stubs.CompositionLocal, CompositionLocalConsumerModifierStub, ModifierNodeStub ) @@ -319,7 +297,7 @@ src/test/NodeUnderTest.kt:16: Error: Reading staticLocalInt lazily will only acc } """ ), - CompositionLocalStub, + Stubs.CompositionLocal, CompositionLocalConsumerModifierStub, ModifierNodeStub ) diff --git a/compose/ui/ui-test-junit4/api/current.txt b/compose/ui/ui-test-junit4/api/current.txt index b73b73b45a9b3..8f3d07af5c5ab 100644 --- a/compose/ui/ui-test-junit4/api/current.txt +++ b/compose/ui/ui-test-junit4/api/current.txt @@ -7,7 +7,7 @@ package androidx.compose.ui.test.junit4 { method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement base, org.junit.runner.Description description); method public suspend Object? awaitIdle(kotlin.coroutines.Continuation); method public void cancelAndRecreateRecomposer(); - method public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); + method @RequiresApi(34) public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); method public A getActivity(); method public R getActivityRule(); method public androidx.compose.ui.unit.Density getDensity(); @@ -17,7 +17,7 @@ package androidx.compose.ui.test.junit4 { method public void registerIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public T runOnIdle(kotlin.jvm.functions.Function0 action); method public T runOnUiThread(kotlin.jvm.functions.Function0 action); - method public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); + method @RequiresApi(34) public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); method public void setContent(kotlin.jvm.functions.Function0 composable); method public void unregisterIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public void waitForIdle(); @@ -26,7 +26,7 @@ package androidx.compose.ui.test.junit4 { method @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public void waitUntilDoesNotExist(androidx.compose.ui.test.SemanticsMatcher matcher, long timeoutMillis); method @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public void waitUntilExactlyOneExists(androidx.compose.ui.test.SemanticsMatcher matcher, long timeoutMillis); method @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public void waitUntilNodeCount(androidx.compose.ui.test.SemanticsMatcher matcher, int count, long timeoutMillis); - property public final com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; + property @RequiresApi(34) public final com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; property public final A activity; property public final R activityRule; property public androidx.compose.ui.unit.Density density; @@ -55,8 +55,8 @@ package androidx.compose.ui.test.junit4 { @kotlin.jvm.JvmDefaultWithCompatibility public interface ComposeTestRule extends org.junit.rules.TestRule androidx.compose.ui.test.SemanticsNodeInteractionsProvider { method public suspend Object? awaitIdle(kotlin.coroutines.Continuation); - method public default void disableAccessibilityChecks(); - method public default void enableAccessibilityChecks(); + method @RequiresApi(34) public default void disableAccessibilityChecks(); + method @RequiresApi(34) public default void enableAccessibilityChecks(); method public androidx.compose.ui.unit.Density getDensity(); method public androidx.compose.ui.test.MainTestClock getMainClock(); method public void registerIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); diff --git a/compose/ui/ui-test-junit4/api/restricted_current.txt b/compose/ui/ui-test-junit4/api/restricted_current.txt index b73b73b45a9b3..8f3d07af5c5ab 100644 --- a/compose/ui/ui-test-junit4/api/restricted_current.txt +++ b/compose/ui/ui-test-junit4/api/restricted_current.txt @@ -7,7 +7,7 @@ package androidx.compose.ui.test.junit4 { method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement base, org.junit.runner.Description description); method public suspend Object? awaitIdle(kotlin.coroutines.Continuation); method public void cancelAndRecreateRecomposer(); - method public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); + method @RequiresApi(34) public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); method public A getActivity(); method public R getActivityRule(); method public androidx.compose.ui.unit.Density getDensity(); @@ -17,7 +17,7 @@ package androidx.compose.ui.test.junit4 { method public void registerIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public T runOnIdle(kotlin.jvm.functions.Function0 action); method public T runOnUiThread(kotlin.jvm.functions.Function0 action); - method public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); + method @RequiresApi(34) public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); method public void setContent(kotlin.jvm.functions.Function0 composable); method public void unregisterIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public void waitForIdle(); @@ -26,7 +26,7 @@ package androidx.compose.ui.test.junit4 { method @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public void waitUntilDoesNotExist(androidx.compose.ui.test.SemanticsMatcher matcher, long timeoutMillis); method @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public void waitUntilExactlyOneExists(androidx.compose.ui.test.SemanticsMatcher matcher, long timeoutMillis); method @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public void waitUntilNodeCount(androidx.compose.ui.test.SemanticsMatcher matcher, int count, long timeoutMillis); - property public final com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; + property @RequiresApi(34) public final com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; property public final A activity; property public final R activityRule; property public androidx.compose.ui.unit.Density density; @@ -55,8 +55,8 @@ package androidx.compose.ui.test.junit4 { @kotlin.jvm.JvmDefaultWithCompatibility public interface ComposeTestRule extends org.junit.rules.TestRule androidx.compose.ui.test.SemanticsNodeInteractionsProvider { method public suspend Object? awaitIdle(kotlin.coroutines.Continuation); - method public default void disableAccessibilityChecks(); - method public default void enableAccessibilityChecks(); + method @RequiresApi(34) public default void disableAccessibilityChecks(); + method @RequiresApi(34) public default void enableAccessibilityChecks(); method public androidx.compose.ui.unit.Density getDensity(); method public androidx.compose.ui.test.MainTestClock getMainClock(); method public void registerIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); diff --git a/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.android.kt b/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.android.kt index 45f1c50daf13a..4204c5e534174 100644 --- a/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.android.kt +++ b/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.android.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.test.junit4 import androidx.activity.ComponentActivity +import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.test.AndroidComposeUiTestEnvironment import androidx.compose.ui.test.ExperimentalTestApi @@ -326,8 +327,12 @@ private constructor( * * The default value is `null`. * + * This requires API 34+ (Android U), and currently does not work on Robolectric. + * * @sample androidx.compose.ui.test.samples.accessibilityChecks_withAndroidComposeTestRule_sample */ + @get:RequiresApi(34) + @set:RequiresApi(34) var accessibilityValidator: AccessibilityValidator? get() = composeTest.accessibilityValidator set(value) { @@ -382,9 +387,12 @@ private constructor( * This will create and set an [accessibilityValidator] if there isn't one yet, or will do * nothing if an `accessibilityValidator` is already set. * + * This requires API 34+ (Android U), and currently does not work on Robolectric. + * * @sample androidx.compose.ui.test.samples.accessibilityChecks_withComposeTestRule_sample * @see disableAccessibilityChecks */ + @RequiresApi(34) override fun enableAccessibilityChecks() = composeTest.enableAccessibilityChecks() /** @@ -395,6 +403,7 @@ private constructor( * @sample androidx.compose.ui.test.samples.accessibilityChecks_withAndroidComposeTestRule_sample * @see enableAccessibilityChecks */ + @RequiresApi(34) override fun disableAccessibilityChecks() = composeTest.disableAccessibilityChecks() override fun onNode( diff --git a/compose/ui/ui-test-junit4/src/jvmMain/kotlin/androidx/compose/ui/test/junit4/ComposeTestRule.jvm.kt b/compose/ui/ui-test-junit4/src/jvmMain/kotlin/androidx/compose/ui/test/junit4/ComposeTestRule.jvm.kt index 7589a1de1efbe..56f20293b1790 100644 --- a/compose/ui/ui-test-junit4/src/jvmMain/kotlin/androidx/compose/ui/test/junit4/ComposeTestRule.jvm.kt +++ b/compose/ui/ui-test-junit4/src/jvmMain/kotlin/androidx/compose/ui/test/junit4/ComposeTestRule.jvm.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.test.junit4 +import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.IdlingResource @@ -235,9 +236,12 @@ interface ComposeTestRule : TestRule, SemanticsNodeInteractionsProvider { * Accessibility checks are platform dependent, refer to the documentation of the platform * specific variant of [ComposeTestRule] to see if it is supported and how you can configure it. * + * On Android, this requires API 34+ (Android U), and currently does not work on Robolectric. + * * @sample androidx.compose.ui.test.samples.accessibilityChecks_withComposeTestRule_sample * @see disableAccessibilityChecks */ + @RequiresApi(34) fun enableAccessibilityChecks() { throw NotImplementedError("Accessibility Checks are not implemented on this platform") } @@ -248,6 +252,7 @@ interface ComposeTestRule : TestRule, SemanticsNodeInteractionsProvider { * @sample androidx.compose.ui.test.samples.accessibilityChecks_withAndroidComposeTestRule_sample * @see enableAccessibilityChecks */ + @RequiresApi(34) fun disableAccessibilityChecks() { throw NotImplementedError("Accessibility Checks are not implemented on this platform") } diff --git a/compose/ui/ui-test/api/current.txt b/compose/ui/ui-test/api/current.txt index 21ab6f74cba87..1a28106a30649 100644 --- a/compose/ui/ui-test/api/current.txt +++ b/compose/ui/ui-test/api/current.txt @@ -100,20 +100,20 @@ package androidx.compose.ui.test { @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public sealed interface ComposeUiTest extends androidx.compose.ui.test.SemanticsNodeInteractionsProvider { method public suspend Object? awaitIdle(kotlin.coroutines.Continuation); - method public void disableAccessibilityChecks(); - method public void enableAccessibilityChecks(); - method public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); + method @RequiresApi(34) public void disableAccessibilityChecks(); + method @RequiresApi(34) public void enableAccessibilityChecks(); + method @RequiresApi(34) public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); method public androidx.compose.ui.unit.Density getDensity(); method public androidx.compose.ui.test.MainTestClock getMainClock(); method public void registerIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public T runOnIdle(kotlin.jvm.functions.Function0 action); method public T runOnUiThread(kotlin.jvm.functions.Function0 action); - method public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); + method @RequiresApi(34) public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); method public void setContent(kotlin.jvm.functions.Function0 composable); method public void unregisterIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public void waitForIdle(); method public void waitUntil(optional String? conditionDescription, optional long timeoutMillis, kotlin.jvm.functions.Function0 condition); - property public abstract com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; + property @RequiresApi(34) public abstract com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; property public abstract androidx.compose.ui.unit.Density density; property public abstract androidx.compose.ui.test.MainTestClock mainClock; } diff --git a/compose/ui/ui-test/api/restricted_current.txt b/compose/ui/ui-test/api/restricted_current.txt index 2f5bc6c32aa4a..0e101d0a9523d 100644 --- a/compose/ui/ui-test/api/restricted_current.txt +++ b/compose/ui/ui-test/api/restricted_current.txt @@ -100,20 +100,20 @@ package androidx.compose.ui.test { @SuppressCompatibility @androidx.compose.ui.test.ExperimentalTestApi public sealed interface ComposeUiTest extends androidx.compose.ui.test.SemanticsNodeInteractionsProvider { method public suspend Object? awaitIdle(kotlin.coroutines.Continuation); - method public void disableAccessibilityChecks(); - method public void enableAccessibilityChecks(); - method public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); + method @RequiresApi(34) public void disableAccessibilityChecks(); + method @RequiresApi(34) public void enableAccessibilityChecks(); + method @RequiresApi(34) public com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? getAccessibilityValidator(); method public androidx.compose.ui.unit.Density getDensity(); method public androidx.compose.ui.test.MainTestClock getMainClock(); method public void registerIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public T runOnIdle(kotlin.jvm.functions.Function0 action); method public T runOnUiThread(kotlin.jvm.functions.Function0 action); - method public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); + method @RequiresApi(34) public void setAccessibilityValidator(com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator?); method public void setContent(kotlin.jvm.functions.Function0 composable); method public void unregisterIdlingResource(androidx.compose.ui.test.IdlingResource idlingResource); method public void waitForIdle(); method public void waitUntil(optional String? conditionDescription, optional long timeoutMillis, kotlin.jvm.functions.Function0 condition); - property public abstract com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; + property @RequiresApi(34) public abstract com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator? accessibilityValidator; property public abstract androidx.compose.ui.unit.Density density; property public abstract androidx.compose.ui.test.MainTestClock mainClock; } @@ -217,6 +217,7 @@ package androidx.compose.ui.test { @Deprecated public final class GestureScope { ctor @Deprecated public GestureScope(androidx.compose.ui.semantics.SemanticsNode node, androidx.compose.ui.test.TestContext testContext); method @Deprecated public long getVisibleSize(); + property @Deprecated @kotlin.PublishedApi internal final androidx.compose.ui.test.MultiModalInjectionScope delegateScope; property @Deprecated public final long visibleSize; field @Deprecated @kotlin.PublishedApi internal final androidx.compose.ui.test.MultiModalInjectionScope delegateScope; } diff --git a/compose/ui/ui-test/build.gradle b/compose/ui/ui-test/build.gradle index 945688c143409..4941fe7022764 100644 --- a/compose/ui/ui-test/build.gradle +++ b/compose/ui/ui-test/build.gradle @@ -149,6 +149,9 @@ if (AndroidXComposePlugin.isMultiplatformEnabled(project)) { skikoMain { dependsOn(commonMain) + dependencies { + implementation(libs.atomicFu) + } } desktopMain.dependsOn(skikoMain) diff --git a/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt b/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt index 084fbb478ecc4..d345fcbd611f2 100644 --- a/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt +++ b/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt @@ -20,13 +20,13 @@ import androidx.annotation.Sampled import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.roundToAndroidXInsets import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.DarkMode import androidx.compose.ui.test.DeviceConfigurationOverride import androidx.compose.ui.test.FontScale @@ -40,8 +40,10 @@ import androidx.compose.ui.test.then import androidx.compose.ui.text.intl.LocaleList import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect import androidx.core.view.WindowInsetsCompat @Sampled @@ -115,16 +117,22 @@ fun DeviceConfigurationOverrideRoundScreenSample() { @Sampled @Composable fun DeviceConfigurationOverrideWindowInsetsSample() { + fun IntRect.toAndroidXInsets() = androidx.core.graphics.Insets.of(left, top, right, bottom) + DeviceConfigurationOverride( DeviceConfigurationOverride.WindowInsets( WindowInsetsCompat.Builder() .setInsets( WindowInsetsCompat.Type.captionBar(), - DpRect(0.dp, 64.dp, 0.dp, 0.dp).roundToAndroidXInsets(), + with(LocalDensity.current) { DpRect(0.dp, 64.dp, 0.dp, 0.dp).toRect() } + .roundToIntRect() + .toAndroidXInsets() ) .setInsets( WindowInsetsCompat.Type.navigationBars(), - DpRect(24.dp, 0.dp, 48.dp, 24.dp).roundToAndroidXInsets(), + with(LocalDensity.current) { DpRect(24.dp, 0.dp, 48.dp, 24.dp).toRect() } + .roundToIntRect() + .toAndroidXInsets() ) .build() ) diff --git a/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ComposeUiTestTest.kt b/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ComposeUiTestTest.kt index 4f3089f18de06..42f3de7514f69 100644 --- a/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ComposeUiTestTest.kt +++ b/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ComposeUiTestTest.kt @@ -18,7 +18,7 @@ package androidx.compose.ui.test import androidx.activity.ComponentActivity import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.ScrollState import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollScope @@ -148,7 +148,7 @@ class ComposeUiTestTest { setContent { WithTouchSlop(touchSlop = touchSlop) { // turn off visual overscroll for calculation correctness - CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + CompositionLocalProvider(LocalOverscrollFactory provides null) { Box(Modifier.fillMaxSize()) { Column( Modifier.requiredSize(200.dp) diff --git a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt index 72a14576521f7..7ee1e91ed5668 100644 --- a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt +++ b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt @@ -16,11 +16,12 @@ package androidx.compose.ui.test -import android.os.Build +import android.util.Log import android.view.View import android.view.ViewGroup import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.Recomposer import androidx.compose.ui.InternalComposeUiApi @@ -355,7 +356,7 @@ abstract class AndroidComposeUiTestEnvironment( * down after running the test. */ fun runTest(block: AndroidComposeUiTest.() -> R): R { - if (Build.FINGERPRINT.lowercase() == "robolectric") { + if (HasRobolectricFingerprint) { idlingStrategy = RobolectricIdlingStrategy(composeRootRegistry, composeIdlingResource) } // Need to await quiescence before registering our ComposeIdlingResource because the host @@ -456,9 +457,15 @@ abstract class AndroidComposeUiTestEnvironment( override val mainClock: MainTestClock get() = mainClockImpl + @get:RequiresApi(34) + @set:RequiresApi(34) override var accessibilityValidator: AccessibilityValidator? get() = testContext.platform.accessibilityValidator set(value) { + if (HasRobolectricFingerprint) { + // TODO(b/332778271): Remove this warning when said bug is fixed + Log.w(TAG, "Accessibility checks are currently not supported by Robolectric") + } testContext.platform.accessibilityValidator = value } @@ -519,10 +526,12 @@ abstract class AndroidComposeUiTestEnvironment( idlingResourceRegistry.unregisterIdlingResource(idlingResource) } + @RequiresApi(34) override fun enableAccessibilityChecks() { accessibilityValidator = AccessibilityValidator().setRunChecksFromRootView(true) } + @RequiresApi(34) override fun disableAccessibilityChecks() { accessibilityValidator = null } @@ -615,6 +624,10 @@ abstract class AndroidComposeUiTestEnvironment( return composeRootRegistry.getRegisteredComposeRoots() } } + + private companion object { + val TAG = "ComposeUiTest" + } } internal fun ActivityScenario.getActivity(): A? { @@ -637,9 +650,11 @@ actual sealed interface ComposeUiTest : SemanticsNodeInteractionsProvider { * * The default value is `null`. * + * This requires API 34+ (Android U), and currently does not work on Robolectric. + * * @sample androidx.compose.ui.test.samples.accessibilityChecks_withAndroidComposeUiTest_sample */ - var accessibilityValidator: AccessibilityValidator? + @get:RequiresApi(34) @set:RequiresApi(34) var accessibilityValidator: AccessibilityValidator? actual fun runOnUiThread(action: () -> T): T @@ -661,7 +676,7 @@ actual sealed interface ComposeUiTest : SemanticsNodeInteractionsProvider { actual fun setContent(composable: @Composable () -> Unit) - actual fun enableAccessibilityChecks() + @RequiresApi(34) actual fun enableAccessibilityChecks() - actual fun disableAccessibilityChecks() + @RequiresApi(34) actual fun disableAccessibilityChecks() } diff --git a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/RobolectricIdlingStrategy.android.kt b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/RobolectricIdlingStrategy.android.kt index 9e1b85289d912..cf35bf9d2ccb4 100644 --- a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/RobolectricIdlingStrategy.android.kt +++ b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/RobolectricIdlingStrategy.android.kt @@ -16,11 +16,21 @@ package androidx.compose.ui.test +import android.os.Build import androidx.test.espresso.AppNotIdleException import androidx.test.espresso.IdlingPolicies import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.Dispatchers +/** + * Whether or not this test is running on Robolectric. + * + * The implementation of this check is widely used but not officially supported and should therefore + * stay internal. + */ +internal val HasRobolectricFingerprint + get() = Build.FINGERPRINT.lowercase() == "robolectric" + /** * Idling strategy for use with Robolectric. * diff --git a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/TestContext.android.kt b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/TestContext.android.kt index f7747d75f547a..8cf76a12b7523 100644 --- a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/TestContext.android.kt +++ b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/TestContext.android.kt @@ -16,10 +16,13 @@ package androidx.compose.ui.test +import androidx.annotation.RequiresApi import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator internal actual fun createPlatformTestContext(): PlatformTestContext = PlatformTestContext() internal actual class PlatformTestContext( + @get:RequiresApi(34) + @set:RequiresApi(34) var accessibilityValidator: AccessibilityValidator? = null ) diff --git a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt index 6e642510bc835..9ddc4454dac02 100644 --- a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt +++ b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt @@ -21,7 +21,7 @@ import android.os.Looper import android.view.MotionEvent import android.view.View import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.ScrollState import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.FlingBehavior @@ -203,7 +203,7 @@ class RobolectricComposeTest { setContent { WithTouchSlop(touchSlop = touchSlop) { // turn off visual overscroll for calculation correctness - CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + CompositionLocalProvider(LocalOverscrollFactory provides null) { Box(Modifier.fillMaxSize()) { Column( Modifier.requiredSize(200.dp) diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt index d08438a07b631..987f728a1da72 100644 --- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt +++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.test +import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Density import kotlin.coroutines.CoroutineContext @@ -193,10 +194,12 @@ expect sealed interface ComposeUiTest : SemanticsNodeInteractionsProvider { * Accessibility checks are platform dependent, refer to the documentation of the platform * specific variant of [ComposeUiTest] to see if it is supported and how you can configure it. * + * On Android, this requires API 34+ (Android U), and currently does not work on Robolectric. + * * @sample androidx.compose.ui.test.samples.accessibilityChecks_withComposeUiTest_sample * @see disableAccessibilityChecks */ - fun enableAccessibilityChecks() + @RequiresApi(34) fun enableAccessibilityChecks() /** * Disables accessibility checks. @@ -204,7 +207,7 @@ expect sealed interface ComposeUiTest : SemanticsNodeInteractionsProvider { * @sample androidx.compose.ui.test.samples.accessibilityChecks_withComposeUiTest_sample * @see enableAccessibilityChecks */ - fun disableAccessibilityChecks() + @RequiresApi(34) fun disableAccessibilityChecks() } /** diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt index c4b468012def5..e832ac55dc301 100644 --- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt +++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt @@ -526,8 +526,7 @@ internal val SemanticsNode.ancestors: Iterable object : Iterable { override fun iterator(): Iterator { return object : Iterator { - @JsName("nextVar") - var next = parent + @JsName("nextVar") var next = parent override fun hasNext(): Boolean { return next != null diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt index c473f4421784b..b01933822f404 100644 --- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt +++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt @@ -61,14 +61,15 @@ constructor( errorMessageOnFail: String? = null, skipDeactivatedNodes: Boolean = true ): SelectionResult { - return selector.map( + val nodes = testContext.testOwner.getAllSemanticsNodes( atLeastOneRootRequired = atLeastOneRootRequired, useUnmergedTree = useUnmergedTree, skipDeactivatedNodes = skipDeactivatedNodes - ), - errorMessageOnFail.orEmpty() - ) + ) + return testContext.testOwner.runOnUiThread { + selector.map(nodes, errorMessageOnFail.orEmpty()) + } } /** @@ -200,15 +201,14 @@ constructor( /** If using the merged tree, performs the same search in the unmerged tree. */ private fun getNodesInUnmergedTree(errorMessageOnFail: String?): List { return if (!useUnmergedTree) { - selector - .map( - testContext.testOwner.getAllSemanticsNodes( - atLeastOneRootRequired = true, - useUnmergedTree = true - ), - errorMessageOnFail.orEmpty() + val nodes = + testContext.testOwner.getAllSemanticsNodes( + atLeastOneRootRequired = true, + useUnmergedTree = true ) - .selectedNodes + testContext.testOwner.runOnUiThread { + selector.map(nodes, errorMessageOnFail.orEmpty()).selectedNodes + } } else { emptyList() } @@ -259,14 +259,11 @@ constructor( errorMessageOnFail: String? = null ): List { if (nodeIds == null) { - return selector - .map( - testContext.testOwner.getAllSemanticsNodes( - atLeastOneRootRequired, - useUnmergedTree - ), - errorMessageOnFail.orEmpty() - ) + val nodes = + testContext.testOwner.getAllSemanticsNodes(atLeastOneRootRequired, useUnmergedTree) + + return testContext.testOwner + .runOnUiThread { selector.map(nodes, errorMessageOnFail.orEmpty()) } .apply { nodeIds = selectedNodes.map { it.id }.toList() } .selectedNodes } diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.kt index bce044ead9c12..8f6e3e240df9d 100644 --- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.kt +++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.test +import androidx.compose.ui.test.platform.makeSynchronizedObject +import androidx.compose.ui.test.platform.synchronized import kotlinx.coroutines.CoroutineExceptionHandler import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @@ -43,9 +45,10 @@ internal class UncaughtExceptionHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { private var exception: Throwable? = null + private val lock = makeSynchronizedObject(this) override fun handleException(context: CoroutineContext, exception: Throwable) { - synchronized(this) { + synchronized(lock) { if (this.exception == null) { this.exception = exception } else { @@ -67,7 +70,7 @@ internal class UncaughtExceptionHandler : * points to fail the test asap after the exception was caught. */ fun throwUncaught() { - synchronized(this) { + synchronized(lock) { val exception = exception if (exception != null) { this.exception = null @@ -76,5 +79,3 @@ internal class UncaughtExceptionHandler : } } } - -internal expect inline fun synchronized(lock: Any, block: () -> T): T diff --git a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.native.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/platform/Synchronization.kt similarity index 56% rename from compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.native.kt rename to compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/platform/Synchronization.kt index 808d8435aa563..d4de626c3d0ae 100644 --- a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.native.kt +++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/platform/Synchronization.kt @@ -14,12 +14,15 @@ * limitations under the License. */ -package androidx.compose.ui.hapticfeedback +package androidx.compose.ui.test.platform + +internal expect class SynchronizedObject /** - * Desktop implementation for [HapticFeedbackType] + * Returns [ref] as a [SynchronizedObject] on platforms where [Any] is a valid [SynchronizedObject], + * or a new [SynchronizedObject] instance if [ref] is null or this is not supported on the current + * platform. */ -internal actual object PlatformHapticFeedbackType { - actual val LongPress: HapticFeedbackType = HapticFeedbackType(0) - actual val TextHandleMove: HapticFeedbackType = HapticFeedbackType(9) -} \ No newline at end of file +internal expect inline fun makeSynchronizedObject(ref: Any? = null): SynchronizedObject + +internal expect inline fun synchronized(lock: SynchronizedObject, block: () -> R): R diff --git a/compose/ui/ui-test/src/jsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jsMain.kt b/compose/ui/ui-test/src/jsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jsMain.kt deleted file mode 100644 index 8e0d5ebcfa7fe..0000000000000 --- a/compose/ui/ui-test/src/jsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jsMain.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2022 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.test - -internal actual inline fun synchronized(lock: Any, block: () -> T) = block() diff --git a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/FrameDeferringContinuationInterceptor.jvm.kt b/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/FrameDeferringContinuationInterceptor.jvm.kt index 3d973094e9bfc..74df735b843e1 100644 --- a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/FrameDeferringContinuationInterceptor.jvm.kt +++ b/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/FrameDeferringContinuationInterceptor.jvm.kt @@ -18,6 +18,8 @@ package androidx.compose.ui.test import androidx.compose.ui.test.FrameDeferringContinuationInterceptor.FrameDeferredContinuation import androidx.compose.ui.test.internal.DelayPropagatingContinuationInterceptorWrapper +import androidx.compose.ui.test.platform.makeSynchronizedObject +import androidx.compose.ui.test.platform.synchronized import kotlin.coroutines.Continuation import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext @@ -35,7 +37,7 @@ internal class FrameDeferringContinuationInterceptor(parentInterceptor: Continua DelayPropagatingContinuationInterceptorWrapper(parentInterceptor) { private val parentDispatcher = parentInterceptor as? CoroutineDispatcher private val toRunTrampolined = ArrayDeque>() - private val lock = Any() + private val lock = makeSynchronizedObject() private var isDeferringContinuations = false val hasTrampolinedTasks: Boolean diff --git a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/IdlingResourceRegistry.jvm.kt b/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/IdlingResourceRegistry.jvm.kt index e8d1d1a34b08d..47286dcd12707 100644 --- a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/IdlingResourceRegistry.jvm.kt +++ b/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/IdlingResourceRegistry.jvm.kt @@ -17,6 +17,8 @@ package androidx.compose.ui.test import androidx.annotation.VisibleForTesting +import androidx.compose.ui.test.platform.makeSynchronizedObject +import androidx.compose.ui.test.platform.synchronized import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -31,7 +33,7 @@ internal constructor(private val pollScopeOverride: CoroutineScope?) : IdlingRes // Publicly facing constructor, that doesn't override the poll scope @OptIn(InternalTestApi::class) constructor() : this(null) - private val lock = Any() + private val lock = makeSynchronizedObject() // All registered IdlingResources, both idle and busy ones private val idlingResources = mutableSetOf() diff --git a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/TestMonotonicFrameClock.jvm.kt b/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/TestMonotonicFrameClock.jvm.kt index a1633eb2d0325..7f0f3a8d77f0a 100644 --- a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/TestMonotonicFrameClock.jvm.kt +++ b/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/TestMonotonicFrameClock.jvm.kt @@ -17,6 +17,8 @@ package androidx.compose.ui.test import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.ui.test.platform.makeSynchronizedObject +import androidx.compose.ui.test.platform.synchronized import kotlin.coroutines.ContinuationInterceptor import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -67,7 +69,7 @@ class TestMonotonicFrameClock( requireNotNull(coroutineScope.coroutineContext[ContinuationInterceptor]) { "TestMonotonicFrameClock's coroutineScope must have a ContinuationInterceptor" } - private val lock = Any() + private val lock = makeSynchronizedObject() private var awaiters = mutableListOf<(Long) -> Unit>() private var spareAwaiters = mutableListOf<(Long) -> Unit>() private var scheduledFrameDispatch = false @@ -135,7 +137,7 @@ class TestMonotonicFrameClock( // waiting for it. val frameTime: Long val toRun = - kotlin.synchronized(lock) { + synchronized(lock) { check(scheduledFrameDispatch) { "frame dispatch not scheduled" } frameTime = delayController.currentTime * 1_000_000 diff --git a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jvm.kt b/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jvm.kt deleted file mode 100644 index bb3196d3c9575..0000000000000 --- a/compose/ui/ui-test/src/jvmMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.jvm.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2022 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.test - -internal actual inline fun synchronized(lock: Any, block: () -> T) = - kotlin.synchronized(lock, block) \ No newline at end of file diff --git a/compose/ui/ui-test/src/nativeMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.nativeMain.kt b/compose/ui/ui-test/src/nativeMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.nativeMain.kt deleted file mode 100644 index a9bddad4222d8..0000000000000 --- a/compose/ui/ui-test/src/nativeMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.nativeMain.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2022 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.test - -internal actual inline fun synchronized(lock: Any, block: () -> T): T = block() // TODO: implement using atomicfu diff --git a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeRootRegistry.skiko.kt b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeRootRegistry.skiko.kt index abf645a053a3e..602a4178d909c 100644 --- a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeRootRegistry.skiko.kt +++ b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeRootRegistry.skiko.kt @@ -19,6 +19,8 @@ package androidx.compose.ui.test import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformRootForTest +import androidx.compose.ui.test.platform.makeSynchronizedObject +import androidx.compose.ui.test.platform.synchronized /** * Registry where all views implementing [PlatformRootForTest] should be registered while they @@ -27,7 +29,7 @@ import androidx.compose.ui.platform.PlatformRootForTest */ @OptIn(InternalComposeUiApi::class) internal class ComposeRootRegistry : PlatformContext.RootForTestListener { - private val lock = Any() + private val lock = makeSynchronizedObject() private val roots = mutableSetOf() /** diff --git a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt index d4596592ccd03..7ab77cb38a2d0 100644 --- a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt +++ b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.platform.makeSynchronizedObject +import androidx.compose.ui.test.platform.synchronized import androidx.compose.ui.text.input.EditCommand import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeOptions @@ -188,6 +190,7 @@ class SkikoComposeUiTest @InternalTestApi constructor( private val testContext = TestContext(testOwner) private val idlingResources = mutableSetOf() + private val idlingResourcesLock = makeSynchronizedObject(idlingResources) fun runTest(block: SkikoComposeUiTest.() -> R): R { return composeRootRegistry.withRegistry { @@ -330,18 +333,18 @@ class SkikoComposeUiTest @InternalTestApi constructor( } override fun registerIdlingResource(idlingResource: IdlingResource) { - synchronized(idlingResources) { + synchronized(idlingResourcesLock) { idlingResources.add(idlingResource) } } override fun unregisterIdlingResource(idlingResource: IdlingResource) { - synchronized(idlingResources) { + synchronized(idlingResourcesLock) { idlingResources.remove(idlingResource) } } - private fun areAllResourcesIdle() = synchronized(idlingResources) { + private fun areAllResourcesIdle() = synchronized(idlingResourcesLock) { idlingResources.all { it.isIdleNow } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/SemanticAutofill.kt b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/platform/Synchronization.skiko.kt similarity index 50% rename from compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/SemanticAutofill.kt rename to compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/platform/Synchronization.skiko.kt index ebdc459d8e7f7..7c96ae6bba7fb 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/SemanticAutofill.kt +++ b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/platform/Synchronization.skiko.kt @@ -13,22 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package androidx.compose.ui.test.platform -package androidx.compose.ui.autofill +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract -/** - * Autofill API. - * - * This interface is available to all composables via a CompositionLocal. The composable can then - * notify the Autofill framework that user values have been committed as required. - */ -internal interface SemanticAutofill { +internal actual class SynchronizedObject : kotlinx.atomicfu.locks.SynchronizedObject() + +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() - /** - * Notify credentials have been committed. - * - * This function is called when the Autofill framework needs to be notified that credentials - * have been entered by the user. - */ - fun notifyAutofillCommit() +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + return kotlinx.atomicfu.locks.synchronized(lock, block) } diff --git a/compose/ui/ui-test/src/wasmJsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.wasmMain.kt b/compose/ui/ui-test/src/wasmJsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.wasmMain.kt deleted file mode 100644 index 8e0d5ebcfa7fe..0000000000000 --- a/compose/ui/ui-test/src/wasmJsMain/kotlin/androidx/compose/ui/test/UncaughtExceptionHandler.wasmMain.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2022 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.test - -internal actual inline fun synchronized(lock: Any, block: () -> T) = block() diff --git a/compose/ui/ui-text/api/current.ignore b/compose/ui/ui-text/api/current.ignore index 4870663a2ea11..4b4444fe37452 100644 --- a/compose/ui/ui-text/api/current.ignore +++ b/compose/ui/ui-text/api/current.ignore @@ -1,9 +1,21 @@ // Baseline format: 1.0 -DefaultValueChange: androidx.compose.ui.text.MultiParagraph#MultiParagraph(androidx.compose.ui.text.AnnotatedString, androidx.compose.ui.text.TextStyle, long, androidx.compose.ui.unit.Density, androidx.compose.ui.text.font.FontFamily.Resolver, java.util.List>, int, boolean) parameter #7: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.MultiParagraph -DefaultValueChange: androidx.compose.ui.text.MultiParagraph#MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics, long, int, boolean) parameter #3: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.MultiParagraph -DefaultValueChange: androidx.compose.ui.text.ParagraphKt#Paragraph(String, androidx.compose.ui.text.TextStyle, long, androidx.compose.ui.unit.Density, androidx.compose.ui.text.font.FontFamily.Resolver, java.util.List>, java.util.List>, int, boolean) parameter #8: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.ParagraphKt.Paragraph -DefaultValueChange: androidx.compose.ui.text.ParagraphKt#Paragraph(androidx.compose.ui.text.ParagraphIntrinsics, long, int, boolean) parameter #3: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.ParagraphKt.Paragraph +RemovedMethod: androidx.compose.ui.text.TextRange#getCollapsed(): + Removed method androidx.compose.ui.text.TextRange.getCollapsed() +RemovedMethod: androidx.compose.ui.text.TextRange#getEnd(): + Removed method androidx.compose.ui.text.TextRange.getEnd() +RemovedMethod: androidx.compose.ui.text.TextRange#getLength(): + Removed method androidx.compose.ui.text.TextRange.getLength() +RemovedMethod: androidx.compose.ui.text.TextRange#getMax(): + Removed method androidx.compose.ui.text.TextRange.getMax() +RemovedMethod: androidx.compose.ui.text.TextRange#getMin(): + Removed method androidx.compose.ui.text.TextRange.getMin() +RemovedMethod: androidx.compose.ui.text.TextRange#getReversed(): + Removed method androidx.compose.ui.text.TextRange.getReversed() +RemovedMethod: androidx.compose.ui.text.TextRange#getStart(): + Removed method androidx.compose.ui.text.TextRange.getStart() +RemovedMethod: androidx.compose.ui.text.style.LineBreak#getStrategy(): + Removed method androidx.compose.ui.text.style.LineBreak.getStrategy() +RemovedMethod: androidx.compose.ui.text.style.LineBreak#getStrictness(): + Removed method androidx.compose.ui.text.style.LineBreak.getStrictness() +RemovedMethod: androidx.compose.ui.text.style.LineBreak#getWordBreak(): + Removed method androidx.compose.ui.text.style.LineBreak.getWordBreak() diff --git a/compose/ui/ui-text/api/current.txt b/compose/ui/ui-text/api/current.txt index 46a0d578634ff..8522308fe295d 100644 --- a/compose/ui/ui-text/api/current.txt +++ b/compose/ui/ui-text/api/current.txt @@ -19,7 +19,7 @@ package androidx.compose.ui.text { method public java.util.List> getStringAnnotations(String tag, int start, int end); method public String getText(); method public java.util.List> getTtsAnnotations(int start, int end); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public java.util.List> getUrlAnnotations(int start, int end); + method @Deprecated public java.util.List> getUrlAnnotations(int start, int end); method public boolean hasEqualAnnotations(androidx.compose.ui.text.AnnotatedString other); method public boolean hasLinkAnnotations(int start, int end); method public boolean hasStringAnnotations(String tag, int start, int end); @@ -47,8 +47,8 @@ package androidx.compose.ui.text { method public void addStringAnnotation(String tag, String annotation, int start, int end); method public void addStyle(androidx.compose.ui.text.ParagraphStyle style, int start, int end); method public void addStyle(androidx.compose.ui.text.SpanStyle style, int start, int end); - method @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public void addTtsAnnotation(androidx.compose.ui.text.TtsAnnotation ttsAnnotation, int start, int end); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public void addUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation, int start, int end); + method public void addTtsAnnotation(androidx.compose.ui.text.TtsAnnotation ttsAnnotation, int start, int end); + method @Deprecated public void addUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation, int start, int end); method public void append(androidx.compose.ui.text.AnnotatedString text); method public void append(androidx.compose.ui.text.AnnotatedString text, int start, int end); method public androidx.compose.ui.text.AnnotatedString.Builder append(char char); @@ -64,7 +64,7 @@ package androidx.compose.ui.text { method public int pushStyle(androidx.compose.ui.text.ParagraphStyle style); method public int pushStyle(androidx.compose.ui.text.SpanStyle style); method public int pushTtsAnnotation(androidx.compose.ui.text.TtsAnnotation ttsAnnotation); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public int pushUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation); + method @Deprecated public int pushUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation); method public androidx.compose.ui.text.AnnotatedString toAnnotatedString(); property public final int length; } @@ -100,9 +100,9 @@ package androidx.compose.ui.text { method public static androidx.compose.ui.text.AnnotatedString decapitalize(androidx.compose.ui.text.AnnotatedString, optional androidx.compose.ui.text.intl.LocaleList localeList); method public static androidx.compose.ui.text.AnnotatedString toLowerCase(androidx.compose.ui.text.AnnotatedString, optional androidx.compose.ui.text.intl.LocaleList localeList); method public static androidx.compose.ui.text.AnnotatedString toUpperCase(androidx.compose.ui.text.AnnotatedString, optional androidx.compose.ui.text.intl.LocaleList localeList); - method @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.TtsAnnotation ttsAnnotation, kotlin.jvm.functions.Function1 block); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.UrlAnnotation urlAnnotation, kotlin.jvm.functions.Function1 block); - method @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, String tag, String annotation, kotlin.jvm.functions.Function1 block); + method public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.TtsAnnotation ttsAnnotation, kotlin.jvm.functions.Function1 block); + method @Deprecated public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.UrlAnnotation urlAnnotation, kotlin.jvm.functions.Function1 block); + method public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, String tag, String annotation, kotlin.jvm.functions.Function1 block); method public static inline R withLink(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.LinkAnnotation link, kotlin.jvm.functions.Function1 block); method public static inline R withStyle(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.ParagraphStyle style, kotlin.jvm.functions.Function1 block); method public static inline R withStyle(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.SpanStyle style, kotlin.jvm.functions.Function1 block); @@ -167,10 +167,10 @@ package androidx.compose.ui.text { public final class MultiParagraph { ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.Font.ResourceLoader resourceLoader); - ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, boolean ellipsis); + ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); ctor public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, optional int overflow); ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, optional int maxLines, optional boolean ellipsis, float width); - ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, long constraints, optional int maxLines, boolean ellipsis); + ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, long constraints, optional int maxLines, optional boolean ellipsis); ctor public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, long constraints, optional int maxLines, optional int overflow); method public float[] fillBoundingBoxes(long range, float[] array, @IntRange(from=0L) int arrayStart); method public androidx.compose.ui.text.style.ResolvedTextDirection getBidiRunDirection(int offset); @@ -291,17 +291,18 @@ package androidx.compose.ui.text { } public final class ParagraphIntrinsicsKt { + method public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, java.util.List> annotations, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders); method @Deprecated public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.Font.ResourceLoader resourceLoader); - method public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver); + method @Deprecated public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver); } public final class ParagraphKt { method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, optional int maxLines, optional boolean ellipsis, float width); - method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, long constraints, optional int maxLines, boolean ellipsis); + method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, long constraints, optional int maxLines, optional boolean ellipsis); method public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, long constraints, optional int maxLines, optional int overflow); method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.Font.ResourceLoader resourceLoader); - method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, boolean ellipsis); + method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); method public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional int overflow); } @@ -622,13 +623,6 @@ package androidx.compose.ui.text { @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TextRange { method public operator boolean contains(int offset); method public operator boolean contains(long other); - method public boolean getCollapsed(); - method public int getEnd(); - method public int getLength(); - method public int getMax(); - method public int getMin(); - method public boolean getReversed(); - method public int getStart(); method public boolean intersects(long other); property public final boolean collapsed; property public final int end; @@ -744,7 +738,7 @@ package androidx.compose.ui.text { public static final class TextStyle.Companion { method public androidx.compose.ui.text.TextStyle getDefault(); - property public final androidx.compose.ui.text.TextStyle Default; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.TextStyle Default; } public final class TextStyleKt { @@ -755,7 +749,7 @@ package androidx.compose.ui.text { public abstract sealed class TtsAnnotation implements androidx.compose.ui.text.AnnotatedString.Annotation { } - @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public final class UrlAnnotation implements androidx.compose.ui.text.AnnotatedString.Annotation { + @Deprecated public final class UrlAnnotation implements androidx.compose.ui.text.AnnotatedString.Annotation { ctor @Deprecated public UrlAnnotation(String url); method @Deprecated public String getUrl(); property @Deprecated public final String url; @@ -841,6 +835,7 @@ package androidx.compose.ui.text.font { } public static final class Font.Companion { + property public static final long MaximumAsyncTimeoutMillis; field public static final long MaximumAsyncTimeoutMillis = 15000L; // 0x3a98L } @@ -994,24 +989,24 @@ package androidx.compose.ui.text.font { method public androidx.compose.ui.text.font.FontWeight getW700(); method public androidx.compose.ui.text.font.FontWeight getW800(); method public androidx.compose.ui.text.font.FontWeight getW900(); - property public final androidx.compose.ui.text.font.FontWeight Black; - property public final androidx.compose.ui.text.font.FontWeight Bold; - property public final androidx.compose.ui.text.font.FontWeight ExtraBold; - property public final androidx.compose.ui.text.font.FontWeight ExtraLight; - property public final androidx.compose.ui.text.font.FontWeight Light; - property public final androidx.compose.ui.text.font.FontWeight Medium; - property public final androidx.compose.ui.text.font.FontWeight Normal; - property public final androidx.compose.ui.text.font.FontWeight SemiBold; - property public final androidx.compose.ui.text.font.FontWeight Thin; - property public final androidx.compose.ui.text.font.FontWeight W100; - property public final androidx.compose.ui.text.font.FontWeight W200; - property public final androidx.compose.ui.text.font.FontWeight W300; - property public final androidx.compose.ui.text.font.FontWeight W400; - property public final androidx.compose.ui.text.font.FontWeight W500; - property public final androidx.compose.ui.text.font.FontWeight W600; - property public final androidx.compose.ui.text.font.FontWeight W700; - property public final androidx.compose.ui.text.font.FontWeight W800; - property public final androidx.compose.ui.text.font.FontWeight W900; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Black; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Bold; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight ExtraBold; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight ExtraLight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Light; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Medium; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Normal; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight SemiBold; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Thin; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W100; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W200; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W300; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W400; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W500; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W600; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W700; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W800; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W900; } public final class FontWeightKt { @@ -1128,15 +1123,15 @@ package androidx.compose.ui.text.input { method public int getSearch(); method public int getSend(); method public int getUnspecified(); - property public final int Default; - property public final int Done; - property public final int Go; - property public final int Next; - property public final int None; - property public final int Previous; - property public final int Search; - property public final int Send; - property public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Default; + property @androidx.compose.runtime.Stable public final int Done; + property @androidx.compose.runtime.Stable public final int Go; + property @androidx.compose.runtime.Stable public final int Next; + property @androidx.compose.runtime.Stable public final int None; + property @androidx.compose.runtime.Stable public final int Previous; + property @androidx.compose.runtime.Stable public final int Search; + property @androidx.compose.runtime.Stable public final int Send; + property @androidx.compose.runtime.Stable public final int Unspecified; } @androidx.compose.runtime.Immutable public final class ImeOptions { @@ -1184,11 +1179,11 @@ package androidx.compose.ui.text.input { method public int getSentences(); method public int getUnspecified(); method public int getWords(); - property public final int Characters; - property public final int None; - property public final int Sentences; - property public final int Unspecified; - property public final int Words; + property @androidx.compose.runtime.Stable public final int Characters; + property @androidx.compose.runtime.Stable public final int None; + property @androidx.compose.runtime.Stable public final int Sentences; + property @androidx.compose.runtime.Stable public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Words; } @kotlin.jvm.JvmInline public final value class KeyboardType { @@ -1206,16 +1201,16 @@ package androidx.compose.ui.text.input { method public int getText(); method public int getUnspecified(); method public int getUri(); - property public final int Ascii; - property public final int Decimal; - property public final int Email; - property public final int Number; - property public final int NumberPassword; - property public final int Password; - property public final int Phone; - property public final int Text; - property public final int Unspecified; - property public final int Uri; + property @androidx.compose.runtime.Stable public final int Ascii; + property @androidx.compose.runtime.Stable public final int Decimal; + property @androidx.compose.runtime.Stable public final int Email; + property @androidx.compose.runtime.Stable public final int Number; + property @androidx.compose.runtime.Stable public final int NumberPassword; + property @androidx.compose.runtime.Stable public final int Password; + property @androidx.compose.runtime.Stable public final int Phone; + property @androidx.compose.runtime.Stable public final int Text; + property @androidx.compose.runtime.Stable public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Uri; } public final class MoveCursorCommand implements androidx.compose.ui.text.input.EditCommand { @@ -1354,7 +1349,7 @@ package androidx.compose.ui.text.input { public static final class VisualTransformation.Companion { method public androidx.compose.ui.text.input.VisualTransformation getNone(); - property public final androidx.compose.ui.text.input.VisualTransformation None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.input.VisualTransformation None; } } @@ -1427,9 +1422,9 @@ package androidx.compose.ui.text.style { method public float getNone(); method public float getSubscript(); method public float getSuperscript(); - property public final float None; - property public final float Subscript; - property public final float Superscript; + property @androidx.compose.runtime.Stable public final float None; + property @androidx.compose.runtime.Stable public final float Subscript; + property @androidx.compose.runtime.Stable public final float Superscript; } public final class BaselineShiftKt { @@ -1452,9 +1447,6 @@ package androidx.compose.ui.text.style { @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class LineBreak { ctor public LineBreak(int strategy, int strictness, int wordBreak); method public int copy(optional int strategy, optional int strictness, optional int wordBreak); - method public int getStrategy(); - method public int getStrictness(); - method public int getWordBreak(); property public final int strategy; property public final int strictness; property public final int wordBreak; @@ -1466,10 +1458,10 @@ package androidx.compose.ui.text.style { method public int getParagraph(); method public int getSimple(); method public int getUnspecified(); - property public final int Heading; - property public final int Paragraph; - property public final int Simple; - property public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Heading; + property @androidx.compose.runtime.Stable public final int Paragraph; + property @androidx.compose.runtime.Stable public final int Simple; + property @androidx.compose.runtime.Stable public final int Unspecified; } @kotlin.jvm.JvmInline public static final value class LineBreak.Strategy { @@ -1617,9 +1609,9 @@ package androidx.compose.ui.text.style { method public androidx.compose.ui.text.style.TextDecoration getLineThrough(); method public androidx.compose.ui.text.style.TextDecoration getNone(); method public androidx.compose.ui.text.style.TextDecoration getUnderline(); - property public final androidx.compose.ui.text.style.TextDecoration LineThrough; - property public final androidx.compose.ui.text.style.TextDecoration None; - property public final androidx.compose.ui.text.style.TextDecoration Underline; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextDecoration LineThrough; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextDecoration None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextDecoration Underline; } @kotlin.jvm.JvmInline public final value class TextDirection { @@ -1672,7 +1664,7 @@ package androidx.compose.ui.text.style { public static final class TextIndent.Companion { method public androidx.compose.ui.text.style.TextIndent getNone(); - property public final androidx.compose.ui.text.style.TextIndent None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextIndent None; } public final class TextIndentKt { @@ -1700,11 +1692,11 @@ package androidx.compose.ui.text.style { method public int getMiddleEllipsis(); method public int getStartEllipsis(); method public int getVisible(); - property public final int Clip; - property public final int Ellipsis; - property public final int MiddleEllipsis; - property public final int StartEllipsis; - property public final int Visible; + property @androidx.compose.runtime.Stable public final int Clip; + property @androidx.compose.runtime.Stable public final int Ellipsis; + property @androidx.compose.runtime.Stable public final int MiddleEllipsis; + property @androidx.compose.runtime.Stable public final int StartEllipsis; + property @androidx.compose.runtime.Stable public final int Visible; } } diff --git a/compose/ui/ui-text/api/desktop/ui-text.api b/compose/ui/ui-text/api/desktop/ui-text.api index 735172bea216e..8c8a21c42224e 100644 --- a/compose/ui/ui-text/api/desktop/ui-text.api +++ b/compose/ui/ui-text/api/desktop/ui-text.api @@ -316,8 +316,10 @@ public abstract interface class androidx/compose/ui/text/ParagraphIntrinsics { } public final class androidx/compose/ui/text/ParagraphIntrinsicsKt { + public static final fun ParagraphIntrinsics (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;Ljava/util/List;Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;)Landroidx/compose/ui/text/ParagraphIntrinsics; public static final fun ParagraphIntrinsics (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;Ljava/util/List;Ljava/util/List;Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/Font$ResourceLoader;)Landroidx/compose/ui/text/ParagraphIntrinsics; public static final fun ParagraphIntrinsics (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;Ljava/util/List;Ljava/util/List;Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;)Landroidx/compose/ui/text/ParagraphIntrinsics; + public static synthetic fun ParagraphIntrinsics$default (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;Ljava/util/List;Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;ILjava/lang/Object;)Landroidx/compose/ui/text/ParagraphIntrinsics; public static synthetic fun ParagraphIntrinsics$default (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;Ljava/util/List;Ljava/util/List;Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/Font$ResourceLoader;ILjava/lang/Object;)Landroidx/compose/ui/text/ParagraphIntrinsics; public static synthetic fun ParagraphIntrinsics$default (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;Ljava/util/List;Ljava/util/List;Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;ILjava/lang/Object;)Landroidx/compose/ui/text/ParagraphIntrinsics; } @@ -329,11 +331,11 @@ public final class androidx/compose/ui/text/ParagraphKt { public static synthetic fun Paragraph$default (Landroidx/compose/ui/text/ParagraphIntrinsics;IZFILjava/lang/Object;)Landroidx/compose/ui/text/Paragraph; public static synthetic fun Paragraph$default (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;FLandroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;Ljava/util/List;IZILjava/lang/Object;)Landroidx/compose/ui/text/Paragraph; public static synthetic fun Paragraph$default (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;Ljava/util/List;Ljava/util/List;IZFLandroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/Font$ResourceLoader;ILjava/lang/Object;)Landroidx/compose/ui/text/Paragraph; - public static final fun Paragraph-UdtVg6A (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;JLandroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;Ljava/util/List;IZ)Landroidx/compose/ui/text/Paragraph; + public static final synthetic fun Paragraph-UdtVg6A (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;JLandroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;Ljava/util/List;IZ)Landroidx/compose/ui/text/Paragraph; public static synthetic fun Paragraph-UdtVg6A$default (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;JLandroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;Ljava/util/List;IZILjava/lang/Object;)Landroidx/compose/ui/text/Paragraph; public static final fun Paragraph-Ul8oQg4 (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;JLandroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;Ljava/util/List;II)Landroidx/compose/ui/text/Paragraph; public static synthetic fun Paragraph-Ul8oQg4$default (Ljava/lang/String;Landroidx/compose/ui/text/TextStyle;JLandroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily$Resolver;Ljava/util/List;Ljava/util/List;IIILjava/lang/Object;)Landroidx/compose/ui/text/Paragraph; - public static final fun Paragraph-_EkL_-Y (Landroidx/compose/ui/text/ParagraphIntrinsics;JIZ)Landroidx/compose/ui/text/Paragraph; + public static final synthetic fun Paragraph-_EkL_-Y (Landroidx/compose/ui/text/ParagraphIntrinsics;JIZ)Landroidx/compose/ui/text/Paragraph; public static synthetic fun Paragraph-_EkL_-Y$default (Landroidx/compose/ui/text/ParagraphIntrinsics;JIZILjava/lang/Object;)Landroidx/compose/ui/text/Paragraph; public static final fun Paragraph-czeN-Hc (Landroidx/compose/ui/text/ParagraphIntrinsics;JII)Landroidx/compose/ui/text/Paragraph; public static synthetic fun Paragraph-czeN-Hc$default (Landroidx/compose/ui/text/ParagraphIntrinsics;JIIILjava/lang/Object;)Landroidx/compose/ui/text/Paragraph; diff --git a/compose/ui/ui-text/api/restricted_current.ignore b/compose/ui/ui-text/api/restricted_current.ignore index 4870663a2ea11..4b4444fe37452 100644 --- a/compose/ui/ui-text/api/restricted_current.ignore +++ b/compose/ui/ui-text/api/restricted_current.ignore @@ -1,9 +1,21 @@ // Baseline format: 1.0 -DefaultValueChange: androidx.compose.ui.text.MultiParagraph#MultiParagraph(androidx.compose.ui.text.AnnotatedString, androidx.compose.ui.text.TextStyle, long, androidx.compose.ui.unit.Density, androidx.compose.ui.text.font.FontFamily.Resolver, java.util.List>, int, boolean) parameter #7: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.MultiParagraph -DefaultValueChange: androidx.compose.ui.text.MultiParagraph#MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics, long, int, boolean) parameter #3: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.MultiParagraph -DefaultValueChange: androidx.compose.ui.text.ParagraphKt#Paragraph(String, androidx.compose.ui.text.TextStyle, long, androidx.compose.ui.unit.Density, androidx.compose.ui.text.font.FontFamily.Resolver, java.util.List>, java.util.List>, int, boolean) parameter #8: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.ParagraphKt.Paragraph -DefaultValueChange: androidx.compose.ui.text.ParagraphKt#Paragraph(androidx.compose.ui.text.ParagraphIntrinsics, long, int, boolean) parameter #3: - Attempted to remove default value from parameter ellipsis in androidx.compose.ui.text.ParagraphKt.Paragraph +RemovedMethod: androidx.compose.ui.text.TextRange#getCollapsed(): + Removed method androidx.compose.ui.text.TextRange.getCollapsed() +RemovedMethod: androidx.compose.ui.text.TextRange#getEnd(): + Removed method androidx.compose.ui.text.TextRange.getEnd() +RemovedMethod: androidx.compose.ui.text.TextRange#getLength(): + Removed method androidx.compose.ui.text.TextRange.getLength() +RemovedMethod: androidx.compose.ui.text.TextRange#getMax(): + Removed method androidx.compose.ui.text.TextRange.getMax() +RemovedMethod: androidx.compose.ui.text.TextRange#getMin(): + Removed method androidx.compose.ui.text.TextRange.getMin() +RemovedMethod: androidx.compose.ui.text.TextRange#getReversed(): + Removed method androidx.compose.ui.text.TextRange.getReversed() +RemovedMethod: androidx.compose.ui.text.TextRange#getStart(): + Removed method androidx.compose.ui.text.TextRange.getStart() +RemovedMethod: androidx.compose.ui.text.style.LineBreak#getStrategy(): + Removed method androidx.compose.ui.text.style.LineBreak.getStrategy() +RemovedMethod: androidx.compose.ui.text.style.LineBreak#getStrictness(): + Removed method androidx.compose.ui.text.style.LineBreak.getStrictness() +RemovedMethod: androidx.compose.ui.text.style.LineBreak#getWordBreak(): + Removed method androidx.compose.ui.text.style.LineBreak.getWordBreak() diff --git a/compose/ui/ui-text/api/restricted_current.txt b/compose/ui/ui-text/api/restricted_current.txt index ea1b9e1939d8a..4f77959c8228d 100644 --- a/compose/ui/ui-text/api/restricted_current.txt +++ b/compose/ui/ui-text/api/restricted_current.txt @@ -19,7 +19,7 @@ package androidx.compose.ui.text { method public java.util.List> getStringAnnotations(String tag, int start, int end); method public String getText(); method public java.util.List> getTtsAnnotations(int start, int end); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public java.util.List> getUrlAnnotations(int start, int end); + method @Deprecated public java.util.List> getUrlAnnotations(int start, int end); method public boolean hasEqualAnnotations(androidx.compose.ui.text.AnnotatedString other); method public boolean hasLinkAnnotations(int start, int end); method public boolean hasStringAnnotations(String tag, int start, int end); @@ -47,8 +47,8 @@ package androidx.compose.ui.text { method public void addStringAnnotation(String tag, String annotation, int start, int end); method public void addStyle(androidx.compose.ui.text.ParagraphStyle style, int start, int end); method public void addStyle(androidx.compose.ui.text.SpanStyle style, int start, int end); - method @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public void addTtsAnnotation(androidx.compose.ui.text.TtsAnnotation ttsAnnotation, int start, int end); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public void addUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation, int start, int end); + method public void addTtsAnnotation(androidx.compose.ui.text.TtsAnnotation ttsAnnotation, int start, int end); + method @Deprecated public void addUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation, int start, int end); method public void append(androidx.compose.ui.text.AnnotatedString text); method public void append(androidx.compose.ui.text.AnnotatedString text, int start, int end); method public androidx.compose.ui.text.AnnotatedString.Builder append(char char); @@ -64,7 +64,7 @@ package androidx.compose.ui.text { method public int pushStyle(androidx.compose.ui.text.ParagraphStyle style); method public int pushStyle(androidx.compose.ui.text.SpanStyle style); method public int pushTtsAnnotation(androidx.compose.ui.text.TtsAnnotation ttsAnnotation); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public int pushUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation); + method @Deprecated public int pushUrlAnnotation(androidx.compose.ui.text.UrlAnnotation urlAnnotation); method public androidx.compose.ui.text.AnnotatedString toAnnotatedString(); property public final int length; } @@ -100,9 +100,9 @@ package androidx.compose.ui.text { method public static androidx.compose.ui.text.AnnotatedString decapitalize(androidx.compose.ui.text.AnnotatedString, optional androidx.compose.ui.text.intl.LocaleList localeList); method public static androidx.compose.ui.text.AnnotatedString toLowerCase(androidx.compose.ui.text.AnnotatedString, optional androidx.compose.ui.text.intl.LocaleList localeList); method public static androidx.compose.ui.text.AnnotatedString toUpperCase(androidx.compose.ui.text.AnnotatedString, optional androidx.compose.ui.text.intl.LocaleList localeList); - method @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.TtsAnnotation ttsAnnotation, kotlin.jvm.functions.Function1 block); - method @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.UrlAnnotation urlAnnotation, kotlin.jvm.functions.Function1 block); - method @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, String tag, String annotation, kotlin.jvm.functions.Function1 block); + method public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.TtsAnnotation ttsAnnotation, kotlin.jvm.functions.Function1 block); + method @Deprecated public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.UrlAnnotation urlAnnotation, kotlin.jvm.functions.Function1 block); + method public static inline R withAnnotation(androidx.compose.ui.text.AnnotatedString.Builder, String tag, String annotation, kotlin.jvm.functions.Function1 block); method public static inline R withLink(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.LinkAnnotation link, kotlin.jvm.functions.Function1 block); method public static inline R withStyle(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.ParagraphStyle style, kotlin.jvm.functions.Function1 block); method public static inline R withStyle(androidx.compose.ui.text.AnnotatedString.Builder, androidx.compose.ui.text.SpanStyle style, kotlin.jvm.functions.Function1 block); @@ -167,10 +167,10 @@ package androidx.compose.ui.text { public final class MultiParagraph { ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.Font.ResourceLoader resourceLoader); - ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, boolean ellipsis); + ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); ctor public MultiParagraph(androidx.compose.ui.text.AnnotatedString annotatedString, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders, optional int maxLines, optional int overflow); ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, optional int maxLines, optional boolean ellipsis, float width); - ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, long constraints, optional int maxLines, boolean ellipsis); + ctor @Deprecated public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, long constraints, optional int maxLines, optional boolean ellipsis); ctor public MultiParagraph(androidx.compose.ui.text.MultiParagraphIntrinsics intrinsics, long constraints, optional int maxLines, optional int overflow); method public float[] fillBoundingBoxes(long range, float[] array, @IntRange(from=0L) int arrayStart); method public androidx.compose.ui.text.style.ResolvedTextDirection getBidiRunDirection(int offset); @@ -291,17 +291,18 @@ package androidx.compose.ui.text { } public final class ParagraphIntrinsicsKt { + method public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, java.util.List> annotations, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> placeholders); method @Deprecated public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.Font.ResourceLoader resourceLoader); - method public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver); + method @Deprecated public static androidx.compose.ui.text.ParagraphIntrinsics ParagraphIntrinsics(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver); } public final class ParagraphKt { method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, optional int maxLines, optional boolean ellipsis, float width); - method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, long constraints, optional int maxLines, boolean ellipsis); + method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, long constraints, optional int maxLines, optional boolean ellipsis); method public static androidx.compose.ui.text.Paragraph Paragraph(androidx.compose.ui.text.ParagraphIntrinsics paragraphIntrinsics, long constraints, optional int maxLines, optional int overflow); method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis, float width, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.Font.ResourceLoader resourceLoader); - method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, boolean ellipsis); + method @Deprecated public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional boolean ellipsis); method public static androidx.compose.ui.text.Paragraph Paragraph(String text, androidx.compose.ui.text.TextStyle style, long constraints, androidx.compose.ui.unit.Density density, androidx.compose.ui.text.font.FontFamily.Resolver fontFamilyResolver, optional java.util.List> spanStyles, optional java.util.List> placeholders, optional int maxLines, optional int overflow); } @@ -622,13 +623,6 @@ package androidx.compose.ui.text { @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TextRange { method public operator boolean contains(int offset); method public operator boolean contains(long other); - method public boolean getCollapsed(); - method public int getEnd(); - method public int getLength(); - method public int getMax(); - method public int getMin(); - method public boolean getReversed(); - method public int getStart(); method public boolean intersects(long other); property public final boolean collapsed; property public final int end; @@ -744,7 +738,7 @@ package androidx.compose.ui.text { public static final class TextStyle.Companion { method public androidx.compose.ui.text.TextStyle getDefault(); - property public final androidx.compose.ui.text.TextStyle Default; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.TextStyle Default; } public final class TextStyleKt { @@ -755,7 +749,7 @@ package androidx.compose.ui.text { public abstract sealed class TtsAnnotation implements androidx.compose.ui.text.AnnotatedString.Annotation { } - @Deprecated @SuppressCompatibility @androidx.compose.ui.text.ExperimentalTextApi public final class UrlAnnotation implements androidx.compose.ui.text.AnnotatedString.Annotation { + @Deprecated public final class UrlAnnotation implements androidx.compose.ui.text.AnnotatedString.Annotation { ctor @Deprecated public UrlAnnotation(String url); method @Deprecated public String getUrl(); property @Deprecated public final String url; @@ -841,6 +835,7 @@ package androidx.compose.ui.text.font { } public static final class Font.Companion { + property public static final long MaximumAsyncTimeoutMillis; field public static final long MaximumAsyncTimeoutMillis = 15000L; // 0x3a98L } @@ -994,24 +989,24 @@ package androidx.compose.ui.text.font { method public androidx.compose.ui.text.font.FontWeight getW700(); method public androidx.compose.ui.text.font.FontWeight getW800(); method public androidx.compose.ui.text.font.FontWeight getW900(); - property public final androidx.compose.ui.text.font.FontWeight Black; - property public final androidx.compose.ui.text.font.FontWeight Bold; - property public final androidx.compose.ui.text.font.FontWeight ExtraBold; - property public final androidx.compose.ui.text.font.FontWeight ExtraLight; - property public final androidx.compose.ui.text.font.FontWeight Light; - property public final androidx.compose.ui.text.font.FontWeight Medium; - property public final androidx.compose.ui.text.font.FontWeight Normal; - property public final androidx.compose.ui.text.font.FontWeight SemiBold; - property public final androidx.compose.ui.text.font.FontWeight Thin; - property public final androidx.compose.ui.text.font.FontWeight W100; - property public final androidx.compose.ui.text.font.FontWeight W200; - property public final androidx.compose.ui.text.font.FontWeight W300; - property public final androidx.compose.ui.text.font.FontWeight W400; - property public final androidx.compose.ui.text.font.FontWeight W500; - property public final androidx.compose.ui.text.font.FontWeight W600; - property public final androidx.compose.ui.text.font.FontWeight W700; - property public final androidx.compose.ui.text.font.FontWeight W800; - property public final androidx.compose.ui.text.font.FontWeight W900; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Black; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Bold; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight ExtraBold; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight ExtraLight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Light; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Medium; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Normal; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight SemiBold; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight Thin; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W100; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W200; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W300; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W400; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W500; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W600; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W700; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W800; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.font.FontWeight W900; } public final class FontWeightKt { @@ -1128,15 +1123,15 @@ package androidx.compose.ui.text.input { method public int getSearch(); method public int getSend(); method public int getUnspecified(); - property public final int Default; - property public final int Done; - property public final int Go; - property public final int Next; - property public final int None; - property public final int Previous; - property public final int Search; - property public final int Send; - property public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Default; + property @androidx.compose.runtime.Stable public final int Done; + property @androidx.compose.runtime.Stable public final int Go; + property @androidx.compose.runtime.Stable public final int Next; + property @androidx.compose.runtime.Stable public final int None; + property @androidx.compose.runtime.Stable public final int Previous; + property @androidx.compose.runtime.Stable public final int Search; + property @androidx.compose.runtime.Stable public final int Send; + property @androidx.compose.runtime.Stable public final int Unspecified; } @androidx.compose.runtime.Immutable public final class ImeOptions { @@ -1184,11 +1179,11 @@ package androidx.compose.ui.text.input { method public int getSentences(); method public int getUnspecified(); method public int getWords(); - property public final int Characters; - property public final int None; - property public final int Sentences; - property public final int Unspecified; - property public final int Words; + property @androidx.compose.runtime.Stable public final int Characters; + property @androidx.compose.runtime.Stable public final int None; + property @androidx.compose.runtime.Stable public final int Sentences; + property @androidx.compose.runtime.Stable public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Words; } @kotlin.jvm.JvmInline public final value class KeyboardType { @@ -1206,16 +1201,16 @@ package androidx.compose.ui.text.input { method public int getText(); method public int getUnspecified(); method public int getUri(); - property public final int Ascii; - property public final int Decimal; - property public final int Email; - property public final int Number; - property public final int NumberPassword; - property public final int Password; - property public final int Phone; - property public final int Text; - property public final int Unspecified; - property public final int Uri; + property @androidx.compose.runtime.Stable public final int Ascii; + property @androidx.compose.runtime.Stable public final int Decimal; + property @androidx.compose.runtime.Stable public final int Email; + property @androidx.compose.runtime.Stable public final int Number; + property @androidx.compose.runtime.Stable public final int NumberPassword; + property @androidx.compose.runtime.Stable public final int Password; + property @androidx.compose.runtime.Stable public final int Phone; + property @androidx.compose.runtime.Stable public final int Text; + property @androidx.compose.runtime.Stable public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Uri; } public final class MoveCursorCommand implements androidx.compose.ui.text.input.EditCommand { @@ -1354,7 +1349,7 @@ package androidx.compose.ui.text.input { public static final class VisualTransformation.Companion { method public androidx.compose.ui.text.input.VisualTransformation getNone(); - property public final androidx.compose.ui.text.input.VisualTransformation None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.input.VisualTransformation None; } } @@ -1438,9 +1433,9 @@ package androidx.compose.ui.text.style { method public float getNone(); method public float getSubscript(); method public float getSuperscript(); - property public final float None; - property public final float Subscript; - property public final float Superscript; + property @androidx.compose.runtime.Stable public final float None; + property @androidx.compose.runtime.Stable public final float Subscript; + property @androidx.compose.runtime.Stable public final float Superscript; } public final class BaselineShiftKt { @@ -1463,9 +1458,6 @@ package androidx.compose.ui.text.style { @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class LineBreak { ctor public LineBreak(int strategy, int strictness, int wordBreak); method public int copy(optional int strategy, optional int strictness, optional int wordBreak); - method public int getStrategy(); - method public int getStrictness(); - method public int getWordBreak(); property public final int strategy; property public final int strictness; property public final int wordBreak; @@ -1477,10 +1469,10 @@ package androidx.compose.ui.text.style { method public int getParagraph(); method public int getSimple(); method public int getUnspecified(); - property public final int Heading; - property public final int Paragraph; - property public final int Simple; - property public final int Unspecified; + property @androidx.compose.runtime.Stable public final int Heading; + property @androidx.compose.runtime.Stable public final int Paragraph; + property @androidx.compose.runtime.Stable public final int Simple; + property @androidx.compose.runtime.Stable public final int Unspecified; } @kotlin.jvm.JvmInline public static final value class LineBreak.Strategy { @@ -1628,9 +1620,9 @@ package androidx.compose.ui.text.style { method public androidx.compose.ui.text.style.TextDecoration getLineThrough(); method public androidx.compose.ui.text.style.TextDecoration getNone(); method public androidx.compose.ui.text.style.TextDecoration getUnderline(); - property public final androidx.compose.ui.text.style.TextDecoration LineThrough; - property public final androidx.compose.ui.text.style.TextDecoration None; - property public final androidx.compose.ui.text.style.TextDecoration Underline; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextDecoration LineThrough; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextDecoration None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextDecoration Underline; } @kotlin.jvm.JvmInline public final value class TextDirection { @@ -1683,7 +1675,7 @@ package androidx.compose.ui.text.style { public static final class TextIndent.Companion { method public androidx.compose.ui.text.style.TextIndent getNone(); - property public final androidx.compose.ui.text.style.TextIndent None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.text.style.TextIndent None; } public final class TextIndentKt { @@ -1711,11 +1703,11 @@ package androidx.compose.ui.text.style { method public int getMiddleEllipsis(); method public int getStartEllipsis(); method public int getVisible(); - property public final int Clip; - property public final int Ellipsis; - property public final int MiddleEllipsis; - property public final int StartEllipsis; - property public final int Visible; + property @androidx.compose.runtime.Stable public final int Clip; + property @androidx.compose.runtime.Stable public final int Ellipsis; + property @androidx.compose.runtime.Stable public final int MiddleEllipsis; + property @androidx.compose.runtime.Stable public final int StartEllipsis; + property @androidx.compose.runtime.Stable public final int Visible; } } diff --git a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/NonLinearFontScalingBenchmark.kt b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/NonLinearFontScalingBenchmark.kt index 1a5476353ea87..8f2fba928a6a2 100644 --- a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/NonLinearFontScalingBenchmark.kt +++ b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/NonLinearFontScalingBenchmark.kt @@ -122,9 +122,11 @@ class NonLinearFontScalingBenchmark( return ParagraphIntrinsics( text = text, - density = density, style = style, - fontFamilyResolver = createFontFamilyResolver(instrumentationContext) + annotations = listOf(), + density = density, + fontFamilyResolver = createFontFamilyResolver(instrumentationContext), + placeholders = listOf() ) } diff --git a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphBenchmark.kt b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphBenchmark.kt index 8251b68f0f42c..07f66b41a33b7 100644 --- a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphBenchmark.kt +++ b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphBenchmark.kt @@ -117,10 +117,11 @@ class ParagraphBenchmark( ): ParagraphIntrinsics { return ParagraphIntrinsics( text = text, - density = Density(density = instrumentationContext.resources.displayMetrics.density), style = TextStyle(fontSize = fontSize), + annotations = spanStyles, + density = Density(density = instrumentationContext.resources.displayMetrics.density), fontFamilyResolver = createFontFamilyResolver(instrumentationContext), - spanStyles = spanStyles + placeholders = listOf() ) } diff --git a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphMethodBenchmark.kt b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphMethodBenchmark.kt index 7834ca4b99cdc..359445b23d6a4 100644 --- a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphMethodBenchmark.kt +++ b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphMethodBenchmark.kt @@ -69,10 +69,11 @@ class ParagraphMethodBenchmark(private val textType: TextType, private val textL } return ParagraphIntrinsics( text = text, - density = Density(density = 1f), style = TextStyle(fontSize = 12.sp), + annotations = spanStyles, + density = Density(density = 1f), fontFamilyResolver = fontFamilyResolver, - spanStyles = spanStyles + placeholders = listOf() ) } diff --git a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt index a5fd8abefb264..463283828f73f 100644 --- a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt +++ b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt @@ -112,9 +112,11 @@ class ParagraphWithLineHeightBenchmark( return ParagraphIntrinsics( text = text, - density = Density(density = instrumentationContext.resources.displayMetrics.density), style = style, - fontFamilyResolver = createFontFamilyResolver(instrumentationContext) + annotations = listOf(), + density = Density(density = instrumentationContext.resources.displayMetrics.density), + fontFamilyResolver = createFontFamilyResolver(instrumentationContext), + placeholders = listOf() ) } diff --git a/compose/ui/ui-text/build.gradle b/compose/ui/ui-text/build.gradle index 4223412d75645..29aac1f79070e 100644 --- a/compose/ui/ui-text/build.gradle +++ b/compose/ui/ui-text/build.gradle @@ -38,7 +38,6 @@ if(!AndroidXComposePlugin.isMultiplatformEnabled(project)) { */ implementation(libs.kotlinStdlibCommon) implementation(libs.kotlinCoroutinesCore) - implementation(libs.atomicFu) api(project(":compose:ui:ui-graphics")) api(project(":compose:ui:ui-unit")) @@ -105,7 +104,6 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { commonMain.dependencies { implementation(libs.kotlinStdlibCommon) implementation(libs.kotlinCoroutinesCore) - implementation(libs.atomicFu) api(project(":compose:ui:ui-graphics")) api(project(":compose:ui:ui-unit")) @@ -129,6 +127,7 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { dependsOn(commonMain) dependencies { api(libs.skikoCommon) + implementation(libs.atomicFu) } } diff --git a/compose/ui/ui-text/lint-baseline.xml b/compose/ui/ui-text/lint-baseline.xml index f33a351e461f3..391c8abcb3b99 100644 --- a/compose/ui/ui-text/lint-baseline.xml +++ b/compose/ui/ui-text/lint-baseline.xml @@ -1,5 +1,5 @@ - + + + + + + + + + () + buildAnnotatedString { + withStyle(ParagraphStyle()) { withStyle(SpanStyle()) { append("a") } } + withAnnotation(tag1, value1) { append("a") } + withStyle(ParagraphStyle()) { append("a") } + withStyle(SpanStyle()) { append("a") } + } + .mapEachParagraphStyle(ParagraphStyle()) { annotatedString, _ -> + strings.add(annotatedString) + } + + assertThat(strings.size).isEqualTo(4) + assertThat(strings[0].annotations).containsExactly(Range(SpanStyle(), 0, 1)) + assertThat(strings[1].annotations) + .containsExactly(Range(StringAnnotation(value1), 0, 1, tag1)) + assertThat(strings[2].annotations).isNull() + assertThat(strings[3].annotations).containsExactly(Range(SpanStyle(), 0, 1)) + } + private fun MultiParagraph.disableAntialias() { paragraphInfoList.forEach { (it.paragraph as AndroidParagraph).textPaint.isAntiAlias = false diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntegrationTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntegrationTest.kt index 9359de698d037..2030dd2f0f52b 100644 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntegrationTest.kt +++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntegrationTest.kt @@ -4600,9 +4600,10 @@ class ParagraphIntegrationTest { ParagraphIntrinsics( text = text, style = TextStyle(fontSize = fontSize, fontFamily = fontFamilyMeasureFont), - spanStyles = listOf(), + annotations = listOf(), density = defaultDensity, - fontFamilyResolver = UncachedFontFamilyResolver(context) + fontFamilyResolver = UncachedFontFamilyResolver(context), + placeholders = listOf() ) val paragraph = diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntrinsicIntegrationTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntrinsicIntegrationTest.kt index 4ea9c2bc559e1..1b47bdea83e2b 100644 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntrinsicIntegrationTest.kt +++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/ParagraphIntrinsicIntegrationTest.kt @@ -189,11 +189,11 @@ class ParagraphIntrinsicIntegrationTest { ): ParagraphIntrinsics { return ParagraphIntrinsics( text = text, - spanStyles = spanStyles, - placeholders = listOf(), style = TextStyle(fontFamily = fontFamilyMeasureFont, fontSize = fontSize).merge(style), + annotations = spanStyles, density = defaultDensity, - fontFamilyResolver = UncachedFontFamilyResolver(context) + fontFamilyResolver = UncachedFontFamilyResolver(context), + placeholders = listOf() ) } } diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/SegmentBreakerTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/SegmentBreakerTest.kt deleted file mode 100644 index 4f550702d2ec3..0000000000000 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/SegmentBreakerTest.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2020 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.text.android - -import android.text.TextDirectionHeuristic -import android.text.TextDirectionHeuristics -import android.text.TextPaint -import androidx.compose.ui.text.android.animation.SegmentBreaker -import androidx.compose.ui.text.android.animation.SegmentType -import androidx.core.content.res.ResourcesCompat -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import androidx.test.platform.app.InstrumentationRegistry -import androidx.testutils.fonts.R -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -/** - * In this test cases, use following notations: - * - L1-LF shows an example strong LTR character. - * - R1-RF shows an example strong RTL character - */ -@SmallTest -@OptIn(InternalPlatformTextApi::class) -@RunWith(AndroidJUnit4::class) -class SegmentBreakerTest { - - private val sampleTypeface = - ResourcesCompat.getFont( - InstrumentationRegistry.getInstrumentation().targetContext, - R.font.sample_font - ) - - private val LTR = TextDirectionHeuristics.LTR - private val RTL = TextDirectionHeuristics.RTL - - private fun getLayout(text: String, dir: TextDirectionHeuristic): LayoutHelper { - val paint = - TextPaint().apply { - textSize = 10f - typeface = sampleTypeface - } - val layout = - StaticLayoutFactory.create(text = text, paint = paint, width = 50, textDir = dir) - return LayoutHelper(layout) - } - - @Test - fun testWhole() { - val layout = getLayout("a b c d e", LTR) - SegmentBreaker.breakOffsets(layout, SegmentType.Document).also { - assertThat(it).isEqualTo(listOf(0, 9)) - } - } - - @Test - fun testParagraph() { - val layout = getLayout("a b c\nd e", LTR) - SegmentBreaker.breakOffsets(layout, SegmentType.Paragraph).also { - assertThat(it).isEqualTo(listOf(0, 6, 9)) - } - } - - @Test - fun testLine() { - // The input (logical): a b c d e f g h - // - // The text is layout as follows: - // |a b c| - // |d e f| - // |g h | - val layout = getLayout("a b c d e f g h", LTR) - SegmentBreaker.breakOffsets(layout, SegmentType.Line).also { - assertThat(it).isEqualTo(listOf(0, 6, 12, 15)) - } - } - - @Test - fun testWords() { - val layout = getLayout("ab cd ef gh", LTR) - SegmentBreaker.breakOffsets(layout, SegmentType.Word).also { - assertThat(it).isEqualTo(listOf(0, 3, 6, 9, 11)) - } - } - - @Test - fun testWords_bidi() { - val layout = getLayout("aא אd אf gא", LTR) - SegmentBreaker.breakOffsets(layout, SegmentType.Word).also { - assertThat(it).isEqualTo(listOf(0, 1, 3, 4, 6, 7, 9, 10, 11)) - } - } - - @Test - fun testChar() { - val layout = getLayout("abcdefg", LTR) - SegmentBreaker.breakOffsets(layout, SegmentType.Character).also { - assertThat(it).isEqualTo(listOf(0, 1, 2, 3, 4, 5, 6, 7)) - } - } - - @Test - fun testChar_grapheme() { - val layout = getLayout("\uD83D\uDE0AA\u030A", LTR) - SegmentBreaker.breakOffsets(layout, SegmentType.Character).also { - assertThat(it).isEqualTo(listOf(0, 2, 4)) - } - } -} diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutLineVisibleEndTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutLineVisibleEndTest.kt index 0814b1ad7e161..8210b3dee314f 100644 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutLineVisibleEndTest.kt +++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutLineVisibleEndTest.kt @@ -23,6 +23,7 @@ import android.text.TextUtils import androidx.core.content.res.ResourcesCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry import androidx.testutils.fonts.R import com.google.common.truth.Truth.assertThat @@ -30,11 +31,10 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -@OptIn(InternalPlatformTextApi::class) @RunWith(AndroidJUnit4::class) @MediumTest class TextLayoutLineVisibleEndTest { - lateinit var sampleTypeface: Typeface + private lateinit var sampleTypeface: Typeface @Before fun setup() { @@ -121,6 +121,7 @@ class TextLayoutLineVisibleEndTest { assertThat(layout.getLineVisibleEnd(0)).isEqualTo(3) } + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) // b/364169257 for details @Test fun excludesLineBreak_whenMaxLinesPresent_withEllipsisStart() { val text = "abc\ndef" @@ -138,6 +139,7 @@ class TextLayoutLineVisibleEndTest { assertThat(layout.getLineVisibleEnd(0)).isEqualTo(3) } + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) // b/364169257 for details @Test fun excludesLineBreak_whenMaxLinesPresent_withEllipsisMiddle() { val text = "abc\ndef" diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/animation/SegmentBreakerBreakSegmentTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/animation/SegmentBreakerBreakSegmentTest.kt deleted file mode 100644 index f627aa1267f43..0000000000000 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/animation/SegmentBreakerBreakSegmentTest.kt +++ /dev/null @@ -1,1634 +0,0 @@ -/* - * Copyright 2020 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.text.android.animation - -import android.text.TextDirectionHeuristic -import android.text.TextDirectionHeuristics -import android.text.TextPaint -import androidx.compose.ui.text.android.InternalPlatformTextApi -import androidx.compose.ui.text.android.LayoutHelper -import androidx.compose.ui.text.android.StaticLayoutFactory -import androidx.core.content.res.ResourcesCompat -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import androidx.test.platform.app.InstrumentationRegistry -import androidx.testutils.fonts.R -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -/** - * In this test cases, use following notations: - * - L1-LF shows an example strong LTR character. - * - R1-RF shows an example strong RTL character - * - SP shows whitespace character (U+0020) - * - LF shows line-feed character (U+000A) - */ -@SmallTest -@OptIn(InternalPlatformTextApi::class) -@RunWith(AndroidJUnit4::class) -class SegmentBreakerBreakSegmentTest { - private val sampleTypeface = - ResourcesCompat.getFont( - InstrumentationRegistry.getInstrumentation().targetContext, - R.font.sample_font - ) - - // Reference Strong LTR character. All characters are supported by sample_font.ttf and they - // have 1em width. - private val L1 = "\u0061" - private val L2 = "\u0062" - private val L3 = "\u0063" - private val L4 = "\u0064" - private val L5 = "\u0065" - private val L6 = "\u0066" - private val L7 = "\u0067" - private val L8 = "\u0068" - private val L9 = "\u0069" - private val LA = "\u006A" - - // Reference Strong RTL character. All characters are supported by sample_font.ttf and they - // have 1em width. - private val R1 = "\u05D1" - private val R2 = "\u05D2" - private val R3 = "\u05D3" - private val R4 = "\u05D4" - private val R5 = "\u05D5" - private val R6 = "\u05D6" - private val R7 = "\u05D7" - private val R8 = "\u05D8" - private val R9 = "\u05D9" - private val RA = "\u05DA" - - // White space character. This is supported by sample_font.ttf and this has 1em width. - private val SP = " " - private val LF = "\n" - - private val LTR = TextDirectionHeuristics.LTR - private val RTL = TextDirectionHeuristics.RTL - - // sample_font.ttf has ascent=1000 and descent=-200, hence the line height is 1.2em. - private val TEXT_SIZE = 10f - private val LINE_HEIGHT = TEXT_SIZE.toInt() - - private fun getLayout(text: String, dir: TextDirectionHeuristic): LayoutHelper { - val paint = - TextPaint().apply { - textSize = TEXT_SIZE - typeface = sampleTypeface - } - val layout = - StaticLayoutFactory.create( - text = text, - paint = paint, - width = 50, - textDir = dir, - includePadding = false - ) - return LayoutHelper(layout) - } - - private fun getSegments( - text: String, - dir: TextDirectionHeuristic, - type: SegmentType, - dropSpaces: Boolean - ): List { - val layout = getLayout(text = text, dir = dir) - return SegmentBreaker.breakSegments( - layoutHelper = layout, - segmentType = type, - dropSpaces = dropSpaces - ) - } - - @Test - fun document_LTRText_LTRPara() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 SP L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6 SP L7 L8| (SP) - // |L9 LA | - // - // Note that trailing whitespace is not counted in line width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$SP$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Document, dropSpaces = false) - assertThat(segments.size).isEqualTo(1) - assertThat(segments[0]) - .isEqualTo( - Segment( - startOffset = 0, - endOffset = text.length, - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - } - - @Test - fun document_LTRText_RTLPara() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 SP L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6 SP L7 L8| (SP) - // | L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$SP$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Document, dropSpaces = false) - assertThat(segments.size).isEqualTo(1) - assertThat(segments[0]) - .isEqualTo( - Segment( - startOffset = 0, - endOffset = text.length, - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - } - - @Test - fun document_RTLText_LTRPara() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 SP R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (SP) |R8 R7 SP R6 R5| - // |RA R9 | - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$SP$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Document, dropSpaces = false) - assertThat(segments.size).isEqualTo(1) - assertThat(segments[0]) - .isEqualTo( - Segment( - startOffset = 0, - endOffset = text.length, - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - } - - @Test - fun document_RTLText_RTLPara() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 SP R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (SP) |R8 R7 SP R6 R5| - // | RA R9| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$SP$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Document, dropSpaces = false) - assertThat(segments.size).isEqualTo(1) - assertThat(segments[0]) - .isEqualTo( - Segment( - startOffset = 0, - endOffset = text.length, - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - } - - @Test - fun paragraph_LTRText_LTRPara() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6 (LF) | - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Paragraph, dropSpaces = false) - assertThat(segments.size).isEqualTo(2) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 9, // LF char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun paragraph_LTRText_RTLPara() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // | L5 L6| (LF) - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Paragraph, dropSpaces = false) - assertThat(segments.size).isEqualTo(2) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 9, // LF char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun paragraph_RTLText_LTRPara() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (LF) |R6 R5 | - // |R8 R7 SP RA R9| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Paragraph, dropSpaces = false) - assertThat(segments.size).isEqualTo(2) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 9, // LF char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun paragraph_RTLText_RTLPara() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // | (LF)R6 R5| - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Paragraph, dropSpaces = false) - assertThat(segments.size).isEqualTo(2) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 9, // LF char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_LTRText_LTRPara_includeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6(LF) | - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Line, dropSpaces = false) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_LTRText_RTLPara_includeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // | L5 L6| (LF) - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Line, dropSpaces = false) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_RTLText_LTRPara_includeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (LF) |R6 R5 | - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Line, dropSpaces = false) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_RTLText_RTLPara_includeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // | (LF)R6 R5| - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Line, dropSpaces = false) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_LTRText_LTRPara_excludeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6(LF) | - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Line, dropSpaces = true) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_LTRText_RTLPara_excludeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // | L5 L6| (LF) - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Line, dropSpaces = true) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 30, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_RTLText_LTRPara_excludeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (LF) |R6 R5 | - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Line, dropSpaces = true) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_RTLText_RTLPara_excludeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // | (LF)R6 R5| - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Line, dropSpaces = true) - assertThat(segments.size).isEqualTo(3) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 30, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun line_Bidi() { - // input (Logical): L1 L2 SP R1 R2 SP R3 R4 SP L3 L4 - // - // |L1 L2 SP (SP) R2 R1| - // |R4 R3 SP L3 L4| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$R1$R2$SP$R3$R4$SP$L3$L4" - val segments = getSegments(text, LTR, SegmentType.Line, dropSpaces = false) - assertThat(segments.size).isEqualTo(2) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 6, // 2nd SP char offset - left = 0, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ) - ) - ) - } - - @Test - fun word_LTRText_LTRPara_includeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6(LF) | - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Word, dropSpaces = false) - assertThat(segments.size).isEqualTo(5) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 0, - top = 0, - right = 30, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 30, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 0, - top = LINE_HEIGHT * 2, - right = 30, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 30, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun word_LTRText_RTLPara_includeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // | L5 L6| (LF) - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Word, dropSpaces = false) - assertThat(segments.size).isEqualTo(5) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 0, - top = 0, - right = 30, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 30, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 30, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 0, - top = LINE_HEIGHT * 2, - right = 30, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 30, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun word_RTLText_LTRPara_includeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (LF) |R6 R5 | - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Word, dropSpaces = false) - assertThat(segments.size).isEqualTo(6) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 20, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 0, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 8, - left = 0, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - // Bidi assigns LF character to LTR. Do we want to include preceding run? - Segment( - startOffset = 8, - endOffset = 9, - left = 20, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 20, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 20, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun word_RTLText_RTLPara_includeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // | (LF)R6 R5| - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Word, dropSpaces = false) - assertThat(segments.size).isEqualTo(5) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 20, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 0, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 30, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 20, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 20, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun word_LTRText_LTRPara_excludeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6(LF) | - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Word, dropSpaces = true) - assertThat(segments.size).isEqualTo(5) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 0, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 30, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 0, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 0, - top = LINE_HEIGHT * 2, - right = 20, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 30, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun word_LTRText_RTLPara_excludeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // | L5 L6| (LF) - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Word, dropSpaces = true) - assertThat(segments.size).isEqualTo(5) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 0, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 30, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 30, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 0, - top = LINE_HEIGHT * 2, - right = 20, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 30, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun word_RTLText_LTRPara_excludeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (LF) |R6 R5 | - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Word, dropSpaces = true) - assertThat(segments.size).isEqualTo(6) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 30, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 0, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 8, - left = 0, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - // Bidi assigns LF character to LTR. Do we want to include preceding run? - Segment( - startOffset = 8, - endOffset = 9, - left = 20, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 30, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 20, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun word_RTLText_RTLPara_excludeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // | (LF)R6 R5| - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Word, dropSpaces = true) - assertThat(segments.size).isEqualTo(5) - assertThat(segments) - .isEqualTo( - listOf( - Segment( - startOffset = 0, - endOffset = 3, // 1st SP char offset - left = 30, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 3, - endOffset = 6, // 2st SP char offset - left = 0, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ), - Segment( - startOffset = 6, - endOffset = 9, // LF char offset - left = 30, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ), - Segment( - startOffset = 9, - endOffset = 12, // 3rd SP char offset - left = 30, - top = LINE_HEIGHT * 2, - right = 50, - bottom = LINE_HEIGHT * 3 - ), - Segment( - startOffset = 12, - endOffset = text.length, - left = 0, - top = LINE_HEIGHT * 2, - right = 20, - bottom = LINE_HEIGHT * 3 - ) - ) - ) - } - - @Test - fun char_LTRText_LTRPara_includeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6(LF) | - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Character, dropSpaces = false) - assertThat(segments.size).isEqualTo(14) - assertThat(segments[0]) - .isEqualTo( - Segment( // L1 character - startOffset = 0, - endOffset = 1, - left = 0, - top = 0, - right = 10, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP location - startOffset = 2, - endOffset = 3, - left = 20, - top = 0, - right = 30, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[5]) - .isEqualTo( - Segment( // 2nd SP location. not rendered. - startOffset = 5, - endOffset = 6, - left = 50, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ) - ) - } - - @Test - fun char_LTRText_RTLPara_includeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // (SP) |L1 L2 SP L3 L4| - // | (LF)L5 L6| - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Character, dropSpaces = false) - assertThat(segments.size).isEqualTo(14) - assertThat(segments[0]) - .isEqualTo( - Segment( // L1 character - startOffset = 0, - endOffset = 1, - left = 0, - top = 0, - right = 10, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP location - startOffset = 2, - endOffset = 3, - left = 20, - top = 0, - right = 30, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[5]) - .isEqualTo( - Segment( // 2nd SP location. not rendered. - startOffset = 5, - endOffset = 6, - left = 0, - top = 0, - right = 0, - bottom = LINE_HEIGHT - ) - ) - } - - @Test - fun char_RTLText_LTRPara_includeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // |R4 R3 SP R2 R1| (SP) - // |R6 R5(LF) | - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Character, dropSpaces = false) - assertThat(segments.size).isEqualTo(14) - assertThat(segments[0]) - .isEqualTo( - Segment( // R1 character - startOffset = 0, - endOffset = 1, - left = 40, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP location - startOffset = 2, - endOffset = 3, - left = 20, - top = 0, - right = 30, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[5]) - .isEqualTo( - Segment( // 2nd SP location. not rendered. - startOffset = 5, - endOffset = 6, - left = 50, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ) - ) - } - - @Test - fun char_RTLText_RTLPara_includeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // | (LF)R6 R5| - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Character, dropSpaces = false) - assertThat(segments.size).isEqualTo(14) - assertThat(segments[0]) - .isEqualTo( - Segment( // R1 character - startOffset = 0, - endOffset = 1, - left = 40, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP location - startOffset = 2, - endOffset = 3, - left = 20, - top = 0, - right = 30, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[5]) - .isEqualTo( - Segment( // 2nd SP location. not rendered. - startOffset = 5, - endOffset = 6, - left = 0, - top = 0, - right = 0, - bottom = LINE_HEIGHT - ) - ) - } - - @Test - fun char_LTRText_LTRPara_excludeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // |L1 L2 SP L3 L4| (SP) - // |L5 L6(LF) | - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, LTR, SegmentType.Character, dropSpaces = true) - assertThat(segments.size).isEqualTo(10) // three spaces and one line feeds are excluded - assertThat(segments[0]) - .isEqualTo( - Segment( // L1 character - startOffset = 0, - endOffset = 1, - left = 0, - top = 0, - right = 10, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP is skipped. L3 character - startOffset = 3, - endOffset = 4, - left = 30, - top = 0, - right = 40, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[4]) - .isEqualTo( - Segment( // 2nd SP is skipped. L5 character - startOffset = 6, - endOffset = 7, - left = 0, - top = LINE_HEIGHT, - right = 10, - bottom = LINE_HEIGHT * 2 - ) - ) - } - - @Test - fun char_LTRText_RTLPara_excludeSpaces() { - // input (Logical): L1 L2 SP L3 L4 SP L5 L6 LF L7 L8 SP L9 LA - // - // (SP) |L1 L2 SP L3 L4| - // | (LF)L5 L6| - // |L7 L8 SP L9 LA| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$L1$L2$SP$L3$L4$SP$L5$L6$LF$L7$L8$SP$L9$LA" - val segments = getSegments(text, RTL, SegmentType.Character, dropSpaces = true) - assertThat(segments.size).isEqualTo(10) // three spaces and one line feeds are excluded - assertThat(segments[0]) - .isEqualTo( - Segment( // L1 character - startOffset = 0, - endOffset = 1, - left = 0, - top = 0, - right = 10, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP is skipped. L3 character - startOffset = 3, - endOffset = 4, - left = 30, - top = 0, - right = 40, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[4]) - .isEqualTo( - Segment( // 2nd SP is skipped. L5 character - startOffset = 6, - endOffset = 7, - left = 30, - top = LINE_HEIGHT, - right = 40, - bottom = LINE_HEIGHT * 2 - ) - ) - } - - @Test - fun char_RTLText_LTRPara_excludeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // (LF) |R6 R5 | - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, LTR, SegmentType.Character, dropSpaces = true) - assertThat(segments.size).isEqualTo(10) // three spaces and one line feeds are excluded - assertThat(segments[0]) - .isEqualTo( - Segment( // R1 character - startOffset = 0, - endOffset = 1, - left = 40, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP is skipped. R3 character - startOffset = 3, - endOffset = 4, - left = 10, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[4]) - .isEqualTo( - Segment( // 2nd SP is skipped. R5 character - startOffset = 6, - endOffset = 7, - left = 10, - top = LINE_HEIGHT, - right = 20, - bottom = LINE_HEIGHT * 2 - ) - ) - } - - @Test - fun char_RTLText_RTLPara_excludeSpaces() { - // input (Logical): R1 R2 SP R3 R4 SP R5 R6 LF R7 T8 SP R9 RA - // - // (SP) |R4 R3 SP R2 R1| - // | (LF)R6 R5| - // |RA R9 SP R8 R7| - // - // Note that trailing whitespace is not counted in line width. The characters with - // parenthesis are not counted as width. - val text = "$R1$R2$SP$R3$R4$SP$R5$R6$LF$R7$R8$SP$R9$RA" - val segments = getSegments(text, RTL, SegmentType.Character, dropSpaces = true) - assertThat(segments.size).isEqualTo(10) // three spaces and one line feeds are excluded - assertThat(segments[0]) - .isEqualTo( - Segment( // R1 character - startOffset = 0, - endOffset = 1, - left = 40, - top = 0, - right = 50, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[2]) - .isEqualTo( - Segment( // 1st SP is skipped. R3 character - startOffset = 3, - endOffset = 4, - left = 10, - top = 0, - right = 20, - bottom = LINE_HEIGHT - ) - ) - assertThat(segments[4]) - .isEqualTo( - Segment( // 2nd SP is skipped. R5 character - startOffset = 6, - endOffset = 7, - left = 40, - top = LINE_HEIGHT, - right = 50, - bottom = LINE_HEIGHT * 2 - ) - ) - } -} diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableStringTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableStringTest.kt index 697cffeda3b83..59bd04fc9272d 100644 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableStringTest.kt +++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableStringTest.kt @@ -340,6 +340,26 @@ class AndroidAccessibilitySpannableStringTest { assertThat(spannableString).hasSpan(URLSpan::class, 5, 10) { it.url == "http://url.com" } } + @Test + fun toAccessibilitySpannableString_zeroLength_linkAnnotation_ignored() { + val annotatedString = buildAnnotatedString { + append("hello") + addLink(LinkAnnotation.Url("url"), 0, 0) + addLink(LinkAnnotation.Clickable("tag") {}, 3, 3) + } + + val spannableString = + annotatedString.toAccessibilitySpannableString( + density, + fontFamilyResolver, + urlSpanCache + ) + + assertThat(spannableString).isInstanceOf(SpannableString::class.java) + assertThat(spannableString).doesNotHaveSpan(URLSpan::class) + assertThat(spannableString).doesNotHaveSpan(ClickableSpan::class) + } + @Test fun fontsInSpanStyles_areIgnored() { // b/232238615 diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphGetRangeForRectTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphGetRangeForRectTest.kt index c6b73583fb50a..d1e5e8a44b961 100644 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphGetRangeForRectTest.kt +++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphGetRangeForRectTest.kt @@ -509,7 +509,7 @@ class AndroidParagraphGetRangeForRectTest { ): AndroidParagraph { return AndroidParagraph( text = text, - spanStyles = spanStyles, + annotations = spanStyles, placeholders = listOf(), style = TextStyle( diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/SpannableExtensionsTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/SpannableExtensionsTest.kt index a905f3112c5e0..f8c92ef92b005 100644 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/SpannableExtensionsTest.kt +++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/platform/SpannableExtensionsTest.kt @@ -515,7 +515,7 @@ class SpannableExtensionsTest { val spannable = SpannableStringBuilder().apply { append(text) } spannable.setSpanStyles( contextTextStyle = TextStyle(), - spanStyles = listOf(AnnotatedString.Range(spanStyle, 0, text.length)), + annotations = listOf(AnnotatedString.Range(spanStyle, 0, text.length)), density = Density(1f, 1f), resolveTypeface = { _, _, _, _ -> Typeface.DEFAULT } ) @@ -533,7 +533,7 @@ class SpannableExtensionsTest { val spannable = SpannableStringBuilder().apply { append(text) } spannable.setSpanStyles( contextTextStyle = TextStyle(), - spanStyles = listOf(AnnotatedString.Range(spanStyle, 0, text.length)), + annotations = listOf(AnnotatedString.Range(spanStyle, 0, text.length)), density = Density(1f, 1f), resolveTypeface = { _, _, _, _ -> Typeface.DEFAULT } ) @@ -550,7 +550,7 @@ class SpannableExtensionsTest { val spannable = SpannableStringBuilder().apply { append(text) } spannable.setSpanStyles( contextTextStyle = TextStyle(), - spanStyles = listOf(AnnotatedString.Range(spanStyle, 0, text.length)), + annotations = listOf(AnnotatedString.Range(spanStyle, 0, text.length)), density = Density(1f, 1f), resolveTypeface = { _, _, _, _ -> Typeface.DEFAULT } ) @@ -565,7 +565,7 @@ class SpannableExtensionsTest { val spannable = SpannableStringBuilder().apply { append(text) } spannable.setSpanStyles( contextTextStyle = TextStyle(), - spanStyles = + annotations = listOf( AnnotatedString.Range(brushStyle, 0, text.length), AnnotatedString.Range(colorStyle, 0, text.length) @@ -589,7 +589,7 @@ class SpannableExtensionsTest { val spannable = SpannableStringBuilder().apply { append(text) } spannable.setSpanStyles( contextTextStyle = TextStyle(), - spanStyles = + annotations = listOf( AnnotatedString.Range(brushStyle, 0, text.length), AnnotatedString.Range(colorStyle, 0, text.length) diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/style/TextLineBreaker.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/style/TextLineBreaker.kt index 55b2fac3001bf..87009fcdbe8d3 100644 --- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/style/TextLineBreaker.kt +++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/style/TextLineBreaker.kt @@ -39,7 +39,7 @@ open class TextLineBreaker { ): Paragraph { return AndroidParagraph( text = text, - spanStyles = listOf(), + annotations = listOf(), placeholders = listOf(), style = textStyle, maxLines = Int.MAX_VALUE, diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt index 7425e873bf381..49d12ab0f8483 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt @@ -72,6 +72,7 @@ import androidx.compose.ui.text.android.selection.getWordStart import androidx.compose.ui.text.android.style.IndentationFixSpan import androidx.compose.ui.text.android.style.PlaceholderSpan import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.internal.requirePrecondition import androidx.compose.ui.text.platform.AndroidParagraphIntrinsics import androidx.compose.ui.text.platform.AndroidTextPaint import androidx.compose.ui.text.platform.extensions.setSpan @@ -107,7 +108,7 @@ internal class AndroidParagraph( constructor( text: String, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, maxLines: Int, overflow: TextOverflow, @@ -119,8 +120,8 @@ internal class AndroidParagraph( AndroidParagraphIntrinsics( text = text, style = style, + annotations = annotations, placeholders = placeholders, - spanStyles = spanStyles, fontFamilyResolver = fontFamilyResolver, density = density ), @@ -134,11 +135,11 @@ internal class AndroidParagraph( @VisibleForTesting internal val charSequence: CharSequence init { - require(constraints.minHeight == 0 && constraints.minWidth == 0) { + requirePrecondition(constraints.minHeight == 0 && constraints.minWidth == 0) { "Setting Constraints.minWidth and Constraints.minHeight is not supported, " + "these should be the default zero values instead." } - require(maxLines >= 1) { "maxLines should be greater than 0" } + requirePrecondition(maxLines >= 1) { "maxLines should be greater than 0" } val style = paragraphIntrinsics.style @@ -384,7 +385,7 @@ internal class AndroidParagraph( * the top, bottom, left and right of a character. */ override fun getBoundingBox(offset: Int): Rect { - require(offset in charSequence.indices) { + requirePrecondition(offset in charSequence.indices) { "offset($offset) is out of bounds [0,${charSequence.length})" } val rectF = layout.getBoundingBox(offset) @@ -425,7 +426,7 @@ internal class AndroidParagraph( } override fun getPathForRange(start: Int, end: Int): Path { - require(start in 0..end && end <= charSequence.length) { + requirePrecondition(start in 0..end && end <= charSequence.length) { "start($start) or end($end) is out of range [0..${charSequence.length}]," + " or start > end!" } @@ -435,7 +436,7 @@ internal class AndroidParagraph( } override fun getCursorRect(offset: Int): Rect { - require(offset in 0..charSequence.length) { + requirePrecondition(offset in 0..charSequence.length) { "offset($offset) is out of bounds [0,${charSequence.length}]" } val horizontal = layout.getPrimaryHorizontal(offset) diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt index 3a81e2410089f..03df14362cb9a 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt @@ -53,8 +53,8 @@ import org.xml.sax.XMLReader /** * Converts a string with HTML tags into [AnnotatedString]. * - * If you define your string in the resources, make sure to use HTML-escaped opening brackets "<" - * instead of "<". + * If you define your string in the resources, make sure to use HTML-escaped opening brackets + * &lt; instead of <. * * For a list of supported tags go check * [Styling with HTML markup](https://developer.android.com/guide/topics/resources/string-resource#StylingWithHTML) diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt index 287c6c90bdb3a..133332e7b85ca 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt @@ -24,6 +24,7 @@ import android.text.TextDirectionHeuristic import android.text.TextPaint import android.text.TextUtils.TruncateAt import androidx.annotation.RequiresApi +import androidx.compose.ui.text.internal.requirePrecondition /** Factory Class for BoringLayout */ @OptIn(InternalPlatformTextApi::class) @@ -74,8 +75,8 @@ internal object BoringLayoutFactory { ellipsize: TruncateAt? = null, ellipsizedWidth: Int = width, ): BoringLayout { - require(width >= 0) { "negative width" } - require(ellipsizedWidth >= 0) { "negative ellipsized width" } + requirePrecondition(width >= 0) { "negative width" } + requirePrecondition(ellipsizedWidth >= 0) { "negative ellipsized width" } return if (Build.VERSION.SDK_INT >= 33) { BoringLayoutFactory33.create( diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt index 781a65131ff84..5871d7f882edc 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt @@ -70,7 +70,7 @@ internal inline fun > List.fastMapTo( @OptIn(ExperimentalContracts::class) internal inline fun List.fastZipWithNext(transform: (T, T) -> R): List { contract { callsInPlace(transform) } - if (size == 0 || size == 1) return emptyList() + if (size <= 1) return emptyList() val result = mutableListOf() var current = get(0) // `until` as we don't want to invoke this for the last element, since that won't have a `next` diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt index 256e74646a82c..01604ab0f48e5 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.text.android.LayoutCompat.HyphenationFrequency import androidx.compose.ui.text.android.LayoutCompat.JustificationMode import androidx.compose.ui.text.android.LayoutCompat.LineBreakStyle import androidx.compose.ui.text.android.LayoutCompat.LineBreakWordStyle +import androidx.compose.ui.text.internal.requirePrecondition import java.lang.reflect.Constructor import java.lang.reflect.InvocationTargetException @@ -139,12 +140,12 @@ private class StaticLayoutParams( val rightIndents: IntArray? ) { init { - require(start in 0..end) { "invalid start value" } - require(end in 0..text.length) { "invalid end value" } - require(maxLines >= 0) { "invalid maxLines value" } - require(width >= 0) { "invalid width value" } - require(ellipsizedWidth >= 0) { "invalid ellipsizedWidth value" } - require(lineSpacingMultiplier >= 0f) { "invalid lineSpacingMultiplier value" } + requirePrecondition(start in 0..end) { "invalid start value" } + requirePrecondition(end in 0..text.length) { "invalid end value" } + requirePrecondition(maxLines >= 0) { "invalid maxLines value" } + requirePrecondition(width >= 0) { "invalid width value" } + requirePrecondition(ellipsizedWidth >= 0) { "invalid ellipsizedWidth value" } + requirePrecondition(lineSpacingMultiplier >= 0f) { "invalid lineSpacingMultiplier value" } } } diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt index 81d7f1971e2ed..5f5fd9a8061bd 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt @@ -72,6 +72,7 @@ import androidx.compose.ui.text.android.style.BaselineShiftSpan import androidx.compose.ui.text.android.style.LineHeightStyleSpan import androidx.compose.ui.text.android.style.getEllipsizedLeftPadding import androidx.compose.ui.text.android.style.getEllipsizedRightPadding +import androidx.compose.ui.text.internal.requirePrecondition import kotlin.math.abs import kotlin.math.ceil import kotlin.math.max @@ -603,7 +604,7 @@ constructor( val range = lineEndOffset - lineStartOffset val minArraySize = range * 2 - require(array.size >= minArraySize) { + requirePrecondition(array.size >= minArraySize) { "array.size - arrayStart must be greater or equal than (endOffset - startOffset) * 2" } @@ -670,15 +671,21 @@ constructor( */ fun fillBoundingBoxes(startOffset: Int, endOffset: Int, array: FloatArray, arrayStart: Int) { val textLength = text.length - require(startOffset >= 0) { "startOffset must be > 0" } - require(startOffset < textLength) { "startOffset must be less than text length" } - require(endOffset > startOffset) { "endOffset must be greater than startOffset" } - require(endOffset <= textLength) { "endOffset must be smaller or equal to text length" } + requirePrecondition(startOffset >= 0) { "startOffset must be > 0" } + requirePrecondition(startOffset < textLength) { + "startOffset must be less than text length" + } + requirePrecondition(endOffset > startOffset) { + "endOffset must be greater than startOffset" + } + requirePrecondition(endOffset <= textLength) { + "endOffset must be smaller or equal to text length" + } val range = endOffset - startOffset val minArraySize = range * 4 - require((array.size - arrayStart) >= minArraySize) { + requirePrecondition((array.size - arrayStart) >= minArraySize) { "array.size - arrayStart must be greater or equal than (endOffset - startOffset) * 4" } diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentBreaker.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentBreaker.android.kt deleted file mode 100644 index b32ca0749adc8..0000000000000 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentBreaker.android.kt +++ /dev/null @@ -1,310 +0,0 @@ -/* - * Copyright 2020 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. - */ - -@file:Suppress("PrimitiveInCollection") - -package androidx.compose.ui.text.android.animation - -import android.text.Layout -import androidx.compose.ui.text.android.CharSequenceCharacterIterator -import androidx.compose.ui.text.android.LayoutHelper -import androidx.compose.ui.text.android.fastForEach -import androidx.compose.ui.text.android.fastZipWithNext -import androidx.compose.ui.text.android.getLineForOffset -import java.text.BreakIterator -import java.util.Locale -import java.util.TreeSet -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min - -/** - * A class represents animation segment. - * - * @param startOffset an inclusive start character offset of this segment. - * @param endOffset an exclusive end character offset of this segment. - * @param left a graphical left position from the layout origin. - * @param top a graphical top position from the layout origin. - * @param right a graphical right position from the layout origin. - * @param bottom a graphical bottom position from the layout origin. - */ -internal data class Segment( - val startOffset: Int, - val endOffset: Int, - val left: Int, - val top: Int, - val right: Int, - val bottom: Int -) - -/** Provide a segmentation breaker for the text animation. */ -internal object SegmentBreaker { - private fun breakInWords(layoutHelper: LayoutHelper): List { - val text = layoutHelper.layout.text - val words = breakWithBreakIterator(text, BreakIterator.getLineInstance(Locale.getDefault())) - - val set = TreeSet().apply { words.fastForEach { add(it) } } - - for (paraIndex in 0 until layoutHelper.paragraphCount) { - val bidi = layoutHelper.analyzeBidi(paraIndex) ?: continue - val paragraphStart = layoutHelper.getParagraphStart(paraIndex) - for (i in 0 until bidi.runCount) { - set.add(bidi.getRunStart(i) + paragraphStart) - } - } - return set.toList() - } - - private fun breakWithBreakIterator(text: CharSequence, breaker: BreakIterator): List { - val iter = CharSequenceCharacterIterator(text, 0, text.length) - - val res = mutableListOf(0) - breaker.text = iter - while (breaker.next() != BreakIterator.DONE) { - res.add(breaker.current()) - } - return res - } - - /** - * Gets all offsets of the given segment type for animation. - * - * @param layoutHelper a layout helper - * @param segmentType a segmentation type - * @return all break offsets of the given segmentation type including 0 and text length. - */ - fun breakOffsets(layoutHelper: LayoutHelper, segmentType: SegmentType): List { - val layout = layoutHelper.layout - val text = layout.text - - return when (segmentType) { - SegmentType.Document -> listOf(0, text.length) - SegmentType.Paragraph -> { - mutableListOf(0).also { - for (i in 0 until layoutHelper.paragraphCount) { - it.add(layoutHelper.getParagraphEnd(i)) - } - } - } - SegmentType.Line -> { - mutableListOf(0).also { - for (i in 0 until layout.lineCount) { - it.add(layout.getLineEnd(i)) - } - } - } - SegmentType.Word -> breakInWords(layoutHelper) - SegmentType.Character -> - breakWithBreakIterator( - text, - BreakIterator.getCharacterInstance(Locale.getDefault()) - ) - } - } - - /** - * Break Layout into list of segments. - * - * A segment represents a unit of text animation. For example, if you specify, SegmentType - * .Line, this function will give you a list of Line segments which have line start offset and - * line end offset, and also line bounding box. - * - * The dropSpaces argument is ignored if segmentType is Document or Paragraph. - * - * If segmentType is Line and dropSpaces is true, this removes trailing spaces. If segmentType - * is Line and dropSpace is false, this use layout width as the right position of the line. - * - * If segmentType is Word and dropSpaces is true, this removes trailing spaces if there. If - * segmentType is Word and dropSpace is false, this includes the trailing whitespace into - * segment. - * - * If segmentType is Character and dropSpace is true, this drops whitespace only segment. If - * segmentType is Character and dropSpace is true, this include whitespace only segment. - * - * @param layoutHelper a layout helper - * @param segmentType a segmentation type - * @param dropSpaces whether dropping spacing. See function comment for more details. - * @return list of segment object - */ - fun breakSegments( - layoutHelper: LayoutHelper, - segmentType: SegmentType, - dropSpaces: Boolean - ): List { - return when (segmentType) { - SegmentType.Document -> breakSegmentWithDocument(layoutHelper) - SegmentType.Paragraph -> breakSegmentWithParagraph(layoutHelper) - SegmentType.Line -> breakSegmentWithLine(layoutHelper, dropSpaces) - SegmentType.Word -> breakSegmentWithWord(layoutHelper, dropSpaces) - SegmentType.Character -> breakSegmentWithChar(layoutHelper, dropSpaces) - } - } - - private fun breakSegmentWithDocument(layoutHelper: LayoutHelper): List { - return listOf( - Segment( - startOffset = 0, - endOffset = layoutHelper.layout.text.length, - left = 0, - top = 0, - right = layoutHelper.layout.width, - bottom = layoutHelper.layout.height - ) - ) - } - - private fun breakSegmentWithParagraph(layoutHelper: LayoutHelper): List { - val result = mutableListOf() - val layout = layoutHelper.layout - for (i in 0 until layoutHelper.paragraphCount) { - val paraStart = layoutHelper.getParagraphStart(i) - val paraEnd = layoutHelper.getParagraphEnd(i) - val paraFirstLine = layout.getLineForOffset(paraStart, false /* downstream */) - val paraLastLine = layout.getLineForOffset(paraEnd, true /* upstream */) - result.add( - Segment( - startOffset = paraStart, - endOffset = paraEnd, - left = 0, - top = layout.getLineTop(paraFirstLine), - right = layout.width, - bottom = layout.getLineBottom(paraLastLine) - ) - ) - } - return result - } - - private fun breakSegmentWithLine( - layoutHelper: LayoutHelper, - dropSpaces: Boolean - ): List { - val result = mutableListOf() - val layout = layoutHelper.layout - for (i in 0 until layoutHelper.layout.lineCount) { - result.add( - Segment( - startOffset = layout.getLineStart(i), - endOffset = layout.getLineEnd(i), - left = if (dropSpaces) ceil(layout.getLineLeft(i)).toInt() else 0, - top = layout.getLineTop(i), - right = if (dropSpaces) ceil(layout.getLineRight(i)).toInt() else layout.width, - bottom = layout.getLineBottom(i) - ) - ) - } - return result - } - - private fun breakSegmentWithWord( - layoutHelper: LayoutHelper, - dropSpaces: Boolean - ): List { - val layout = layoutHelper.layout - val wsWidth = ceil(layout.paint.measureText(" ")).toInt() - return breakOffsets(layoutHelper, SegmentType.Word).fastZipWithNext { start, end -> - val lineNo = layout.getLineForOffset(start, false /* downstream */) - val paraRTL = layout.getParagraphDirection(lineNo) == Layout.DIR_RIGHT_TO_LEFT - val runRtl = layout.isRtlCharAt(start) // no bidi transition inside segment - val startPos = - ceil( - layoutHelper.getHorizontalPosition( - offset = start, - usePrimaryDirection = runRtl == paraRTL, - upstream = false - ) - ) - .toInt() - val endPos = - ceil( - layoutHelper.getHorizontalPosition( - offset = end, - usePrimaryDirection = runRtl == paraRTL, - upstream = true - ) - ) - .toInt() - - // Drop trailing space is the line does not end with this word. - var left = min(startPos, endPos) - var right = max(startPos, endPos) - if (dropSpaces && end != 0 && layout.text[end - 1] == ' ') { - val lineEnd = layout.getLineEnd(lineNo) - if (lineEnd != end) { - if (runRtl) { - left += wsWidth - } else { - right -= wsWidth - } - } - } - - Segment( - startOffset = start, - endOffset = end, - left = left, - top = layout.getLineTop(lineNo), - right = right, - bottom = layout.getLineBottom(lineNo) - ) - } - } - - private fun breakSegmentWithChar( - layoutHelper: LayoutHelper, - dropSpaces: Boolean - ): List { - val res = mutableListOf() - breakOffsets(layoutHelper, SegmentType.Character).fastZipWithNext lambda@{ start, end -> - val layout = layoutHelper.layout - - if (dropSpaces && end == start + 1 && layoutHelper.isLineEndSpace(layout.text[start])) - return@lambda - val lineNo = layout.getLineForOffset(start, false /* downstream */) - val paraRTL = layout.getParagraphDirection(lineNo) == Layout.DIR_RIGHT_TO_LEFT - val runRtl = layout.isRtlCharAt(start) // no bidi transition inside segment - val startPos = - ceil( - layoutHelper.getHorizontalPosition( - offset = start, - usePrimaryDirection = runRtl == paraRTL, - upstream = false - ) - ) - .toInt() - val endPos = - ceil( - layoutHelper.getHorizontalPosition( - offset = end, - usePrimaryDirection = runRtl == paraRTL, - upstream = true - ) - ) - .toInt() - res.add( - Segment( - startOffset = start, - endOffset = end, - left = min(startPos, endPos), - top = layout.getLineTop(lineNo), - right = max(startPos, endPos), - bottom = layout.getLineBottom(lineNo) - ) - ) - } - return res - } -} diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentType.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentType.android.kt deleted file mode 100644 index 739401bea9a08..0000000000000 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/animation/SegmentType.android.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020 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.text.android.animation - -/** Defines a segmentation rule for text animation */ -internal enum class SegmentType { - /** Don't break text and treat whole text as the segment. */ - Document, - - /** Break text with paragraph breaker. */ - Paragraph, - - /** Break text with automated line break position. */ - Line, - - /** - * Break text with word boundary. - * - * Note that this uses line breaking instance of the break iterator. Also this includes Bidi - * transition offset. - */ - Word, - - /** Break text with character (grapheme) boundary. */ - Character -} diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt index 0d43401d8d511..588382f646955 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.text.android.selection import androidx.compose.ui.text.android.CharSequenceCharacterIterator +import androidx.compose.ui.text.internal.requirePrecondition import androidx.emoji2.text.EmojiCompat import java.text.BreakIterator import java.util.Locale @@ -40,8 +41,12 @@ internal class WordIterator(val charSequence: CharSequence, start: Int, end: Int private val iterator: BreakIterator init { - require(start in 0..charSequence.length) { "input start index is outside the CharSequence" } - require(end in 0..charSequence.length) { "input end index is outside the CharSequence" } + requirePrecondition(start in 0..charSequence.length) { + "input start index is outside the CharSequence" + } + requirePrecondition(end in 0..charSequence.length) { + "input end index is outside the CharSequence" + } iterator = BreakIterator.getWordInstance(locale) this.start = max(0, start - WINDOW_WIDTH) this.end = min(charSequence.length, end + WINDOW_WIDTH) @@ -315,8 +320,8 @@ internal class WordIterator(val charSequence: CharSequence, start: Int, end: Int /** Check if the given offset is in the given range. */ private fun checkOffsetIsValid(offset: Int) { - require(offset in start..end) { - ("Invalid offset: $offset. Valid range is [$start , $end]") + requirePrecondition(offset in start..end) { + "Invalid offset: $offset. Valid range is [$start , $end]" } } diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt index e0d6cf03dd73f..11144464c3ee8 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.text.android.style import android.graphics.Paint.FontMetricsInt import androidx.annotation.FloatRange +import androidx.compose.ui.text.internal.checkPrecondition import kotlin.math.abs import kotlin.math.ceil @@ -64,7 +65,9 @@ internal class LineHeightStyleSpan( private set init { - check(topRatio in 0f..1f || topRatio == -1f) { "topRatio should be in [0..1] range or -1" } + checkPrecondition(topRatio in 0f..1f || topRatio == -1f) { + "topRatio should be in [0..1] range or -1" + } } override fun chooseHeight( diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt index 4f665e889af61..8eb5c28e7e3b6 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt @@ -21,6 +21,10 @@ import android.graphics.Canvas import android.graphics.Paint import android.text.style.ReplacementSpan import androidx.annotation.IntDef +import androidx.compose.ui.text.internal.checkPrecondition +import androidx.compose.ui.text.internal.requirePrecondition +import androidx.compose.ui.text.internal.throwIllegalArgumentException +import androidx.compose.ui.text.internal.throwIllegalArgumentExceptionForNullCheck import kotlin.math.ceil import kotlin.math.max import kotlin.math.min @@ -85,7 +89,7 @@ internal class PlaceholderSpan( var widthPx: Int = 0 private set get() { - check(isLaidOut) { "PlaceholderSpan is not laid out yet." } + checkPrecondition(isLaidOut) { "PlaceholderSpan is not laid out yet." } return field } @@ -93,7 +97,7 @@ internal class PlaceholderSpan( var heightPx: Int = 0 private set get() { - check(isLaidOut) { "PlaceholderSpan is not laid out yet." } + checkPrecondition(isLaidOut) { "PlaceholderSpan is not laid out yet." } return field } @@ -111,7 +115,7 @@ internal class PlaceholderSpan( isLaidOut = true val fontSize = paint.textSize fontMetrics = paint.fontMetricsInt - require(fontMetrics.descent > fontMetrics.ascent) { + requirePrecondition(fontMetrics.descent > fontMetrics.ascent) { "Invalid fontMetrics: line height can not be negative." } @@ -119,14 +123,14 @@ internal class PlaceholderSpan( when (widthUnit) { UNIT_SP -> width * pxPerSp UNIT_EM -> width * fontSize - else -> throw IllegalArgumentException("Unsupported unit.") + else -> throwIllegalArgumentExceptionForNullCheck("Unsupported unit.") }.ceilToInt() heightPx = when (heightUnit) { UNIT_SP -> (height * pxPerSp).ceilToInt() UNIT_EM -> (height * fontSize).ceilToInt() - else -> throw IllegalArgumentException("Unsupported unit.") + else -> throwIllegalArgumentExceptionForNullCheck("Unsupported unit.") } fm?.apply { @@ -159,7 +163,7 @@ internal class PlaceholderSpan( if (ascent > -heightPx) { ascent = -heightPx } - else -> throw IllegalArgumentException("Unknown verticalAlign.") + else -> throwIllegalArgumentException("Unknown verticalAlign.") } // make top/bottom at least same as ascent/descent. top = min(fontMetrics.top, ascent) diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt index 21c5b325ea357..073ed59c986ef 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt @@ -20,6 +20,7 @@ package androidx.compose.ui.text.font import android.content.Context import android.graphics.Typeface +import androidx.compose.ui.text.internal.requirePrecondition /** * Describes a system-installed font that may be present on some Android devices. @@ -74,7 +75,7 @@ fun Font( @JvmInline value class DeviceFontFamilyName(val name: String) { init { - require(name.isNotEmpty()) { "name may not be empty" } + requirePrecondition(name.isNotEmpty()) { "name may not be empty" } } } diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.android.kt index 2cf1aa87d7155..3778a32d538d9 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.android.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.ParagraphIntrinsics import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.ceilToInt import androidx.compose.ui.text.font.Font @@ -45,7 +44,7 @@ import androidx.compose.ui.unit.Density internal actual fun ActualParagraph( text: String, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, maxLines: Int, ellipsis: Boolean, @@ -58,7 +57,7 @@ internal actual fun ActualParagraph( text = text, style = style, placeholders = placeholders, - spanStyles = spanStyles, + annotations = annotations, fontFamilyResolver = createFontFamilyResolver(resourceLoader), density = density ), @@ -70,7 +69,7 @@ internal actual fun ActualParagraph( internal actual fun ActualParagraph( text: String, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, maxLines: Int, overflow: TextOverflow, @@ -83,7 +82,7 @@ internal actual fun ActualParagraph( text = text, style = style, placeholders = placeholders, - spanStyles = spanStyles, + annotations = annotations, fontFamilyResolver = fontFamilyResolver, density = density ), diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt index c3783b0fdf94f..5f4509d9703c8 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt @@ -83,21 +83,23 @@ fun AnnotatedString.toAccessibilitySpannableString( } getLinkAnnotations(0, length).fastForEach { linkRange -> - val link = linkRange.item - if (link is LinkAnnotation.Url && link.linkInteractionListener == null) { - spannableString.setSpan( - urlSpanCache.toURLSpan(linkRange.toUrlLink()), - linkRange.start, - linkRange.end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } else { - spannableString.setSpan( - urlSpanCache.toClickableSpan(linkRange), - linkRange.start, - linkRange.end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) + if (linkRange.start != linkRange.end) { + val link = linkRange.item + if (link is LinkAnnotation.Url && link.linkInteractionListener == null) { + spannableString.setSpan( + urlSpanCache.toURLSpan(linkRange.toUrlLink()), + linkRange.start, + linkRange.end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } else { + spannableString.setSpan( + urlSpanCache.toClickableSpan(linkRange), + linkRange.start, + linkRange.end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } } } return spannableString diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt index aaa9578a1c049..8e6a073c5fa94 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt @@ -34,6 +34,9 @@ import androidx.compose.ui.text.font.FontSynthesis import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.ResourceFont import androidx.compose.ui.text.font.synthesizeTypeface +import androidx.compose.ui.text.internal.checkPrecondition +import androidx.compose.ui.text.internal.checkPreconditionNotNull +import androidx.compose.ui.text.internal.throwIllegalStateException import androidx.compose.ui.util.fastDistinctBy import androidx.compose.ui.util.fastFilter import androidx.compose.ui.util.fastFilterNotNull @@ -69,14 +72,14 @@ internal class AndroidFontListTypeface( ?.fastFilterNotNull() ?.fastDistinctBy { it } val targetFonts = matchedFonts ?: blockingFonts - check(targetFonts.isNotEmpty()) { "Could not match font" } + checkPrecondition(targetFonts.isNotEmpty()) { "Could not match font" } val typefaces = mutableMapOf() targetFonts.fastForEach { try { typefaces[it] = AndroidTypefaceCache.getOrCreate(context, it) } catch (e: Exception) { - throw IllegalStateException("Cannot create Typeface from $it") + throwIllegalStateException("Cannot create Typeface from $it") } } @@ -94,10 +97,10 @@ internal class AndroidFontListTypeface( fontMatcher .matchFont(ArrayList(loadedTypefaces.keys), fontWeight, fontStyle) .firstOrNull() - checkNotNull(font) { "Could not load font" } + checkPreconditionNotNull(font) { "Could not load font" } val typeface = loadedTypefaces[font] - checkNotNull(typeface) { "Could not load typeface" } + checkPreconditionNotNull(typeface) { "Could not load typeface" } return synthesis.synthesizeTypeface(typeface, font, fontWeight, fontStyle) as Typeface } diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphHelper.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphHelper.android.kt index 676aab8d36061..fe35ed1176fee 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphHelper.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphHelper.android.kt @@ -25,9 +25,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.DefaultIncludeFontPadding import androidx.compose.ui.text.EmojiSupportMatch import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.android.InternalPlatformTextApi import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontSynthesis @@ -46,12 +44,12 @@ import androidx.emoji2.text.EmojiCompat import androidx.emoji2.text.EmojiCompat.REPLACE_STRATEGY_ALL import androidx.emoji2.text.EmojiCompat.REPLACE_STRATEGY_DEFAULT -@OptIn(InternalPlatformTextApi::class) +@Suppress("UNCHECKED_CAST") internal fun createCharSequence( text: String, contextFontSize: Float, contextTextStyle: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, density: Density, resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface, @@ -74,7 +72,7 @@ internal fun createCharSequence( } if ( - spanStyles.isEmpty() && + annotations.isEmpty() && placeholders.isEmpty() && contextTextStyle.textIndent == TextIndent.None && contextTextStyle.lineHeight.isUnspecified @@ -119,7 +117,7 @@ internal fun createCharSequence( spannableString.setTextIndent(contextTextStyle.textIndent, contextFontSize, density) - spannableString.setSpanStyles(contextTextStyle, spanStyles, density, resolveTypeface) + spannableString.setSpanStyles(contextTextStyle, annotations, density, resolveTypeface) spannableString.setPlaceholders(placeholders, density) diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphIntrinsics.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphIntrinsics.android.kt index 18e909340fa0a..34b8f4e4b82cf 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphIntrinsics.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphIntrinsics.android.kt @@ -22,12 +22,10 @@ import android.view.View import androidx.compose.runtime.State import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.EmojiSupportMatch -import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.ParagraphIntrinsics import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.android.InternalPlatformTextApi import androidx.compose.ui.text.android.LayoutCompat import androidx.compose.ui.text.android.LayoutIntrinsics import androidx.compose.ui.text.font.FontFamily @@ -40,15 +38,14 @@ import androidx.compose.ui.text.platform.extensions.applySpanStyle import androidx.compose.ui.text.platform.extensions.setTextMotion import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density +import androidx.compose.ui.util.fastFirstOrNull import androidx.core.text.TextUtilsCompat import java.util.Locale -@OptIn(InternalPlatformTextApi::class, ExperimentalTextApi::class) -internal class AndroidParagraphIntrinsics -constructor( +internal class AndroidParagraphIntrinsics( val text: String, val style: TextStyle, - val spanStyles: List>, + val annotations: List>, val placeholders: List>, val fontFamilyResolver: FontFamily.Resolver, val density: Density @@ -111,14 +108,15 @@ constructor( style = style.toSpanStyle(), resolveTypeface = resolveTypeface, density = density, - requiresLetterSpacing = spanStyles.isNotEmpty(), + requiresLetterSpacing = + annotations.fastFirstOrNull { it.item is SpanStyle } != null, ) val finalSpanStyles = if (notAppliedStyle != null) { // This is just a prepend operation, written in a lower alloc way // equivalent to: `AnnotatedString.Range(...) + spanStyles` - List(spanStyles.size + 1) { position -> + List(annotations.size + 1) { position -> when (position) { 0 -> AnnotatedString.Range( @@ -126,18 +124,18 @@ constructor( start = 0, end = text.length ) - else -> spanStyles[position - 1] + else -> annotations[position - 1] } } } else { - spanStyles + annotations } charSequence = createCharSequence( text = text, contextFontSize = textPaint.textSize, contextTextStyle = style, - spanStyles = finalSpanStyles, + annotations = finalSpanStyles, placeholders = placeholders, density = density, resolveTypeface = resolveTypeface, @@ -148,8 +146,10 @@ constructor( } } -/** For a given [TextDirection] return [TextLayout] constants for text direction heuristics. */ -@OptIn(InternalPlatformTextApi::class) +/** + * For a given [TextDirection] return [androidx.compose.ui.text.android.TextLayout] constants for + * text direction heuristics. + */ internal fun resolveTextDirectionHeuristics( textDirection: TextDirection, localeList: LocaleList? = null @@ -172,11 +172,10 @@ internal fun resolveTextDirectionHeuristics( } } -@OptIn(InternalPlatformTextApi::class) internal actual fun ActualParagraphIntrinsics( text: String, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, density: Density, fontFamilyResolver: FontFamily.Resolver @@ -186,7 +185,7 @@ internal actual fun ActualParagraphIntrinsics( style = style, placeholders = placeholders, fontFamilyResolver = fontFamilyResolver, - spanStyles = spanStyles, + annotations = annotations, density = density ) diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt index 45c3009f07163..469ac3847e0d3 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt @@ -64,6 +64,14 @@ internal class AndroidTextPaint(flags: Int, density: Float) : TextPaint(flags) { @VisibleForTesting internal var shadow: Shadow = Shadow.None + /** + * Different than other backing properties, this variable only exists to enable easy comparison + * between the last set color and the new color that is going to be set. Color conversion from + * Compose to platform color primitive integer is expensive, so it is more efficient to skip + * this conversion if the color is not going to change at all. + */ + private var lastColor: Color? = null + @VisibleForTesting internal var brush: Brush? = null internal var shaderState: State? = null @@ -99,7 +107,8 @@ internal class AndroidTextPaint(flags: Int, density: Float) : TextPaint(flags) { } fun setColor(color: Color) { - if (color.isSpecified) { + if (lastColor != color && color.isSpecified) { + this.lastColor = color this.color = color.toArgb() clearShader() } @@ -132,6 +141,7 @@ internal class AndroidTextPaint(flags: Int, density: Float) : TextPaint(flags) { } } composePaint.shader = this.shaderState?.value + this.lastColor = null setAlpha(alpha) } } diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt index 80672e06cf3bc..f653abc970abe 100644 --- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt +++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.text.android.style.ShadowSpan import androidx.compose.ui.text.android.style.SkewXSpan import androidx.compose.ui.text.android.style.TextDecorationSpan import androidx.compose.ui.text.android.style.TypefaceSpan -import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontSynthesis @@ -71,7 +70,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.isUnspecified import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.fastFilter +import androidx.compose.ui.util.fastFilteredMap import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachIndexed import kotlin.math.ceil @@ -182,23 +181,24 @@ private fun isNonLinearFontScalingActive(density: Density) = density.fontScale > internal fun Spannable.setSpanStyles( contextTextStyle: TextStyle, - spanStyles: List>, + annotations: List>, density: Density, resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface, ) { - setFontAttributes(contextTextStyle, spanStyles, resolveTypeface) + setFontAttributes(contextTextStyle, annotations, resolveTypeface) var hasLetterSpacing = false - for (i in spanStyles.indices) { - val spanStyleRange = spanStyles[i] - val start = spanStyleRange.start - val end = spanStyleRange.end + for (i in annotations.indices) { + val annotationRange = annotations[i] + if (annotationRange.item !is SpanStyle) continue + val start = annotationRange.start + val end = annotationRange.end if (start < 0 || start >= length || end <= start || end > length) continue - setSpanStyle(spanStyleRange, density) + setSpanStyle(annotationRange.item, start, end, density) - if (spanStyleRange.item.needsLetterSpacingSpan) { + if (annotationRange.item.needsLetterSpacingSpan) { hasLetterSpacing = true } } @@ -209,12 +209,12 @@ internal fun Spannable.setSpanStyles( // letterSpacing relies on the fontSize on [Paint] to compute Px/Sp from Em. So it must be // applied after all spans that changes the fontSize. - for (i in spanStyles.indices) { - val spanStyleRange = spanStyles[i] + for (i in annotations.indices) { + val spanStyleRange = annotations[i] + val style = spanStyleRange.item + if (style !is SpanStyle) continue val start = spanStyleRange.start val end = spanStyleRange.end - val style = spanStyleRange.item - if (start < 0 || start >= length || end <= start || end > length) continue createLetterSpacingSpan(style.letterSpacing, density)?.let { setSpan(it, start, end) } @@ -222,14 +222,7 @@ internal fun Spannable.setSpanStyles( } } -private fun Spannable.setSpanStyle( - spanStyleRange: AnnotatedString.Range, - density: Density -) { - val start = spanStyleRange.start - val end = spanStyleRange.end - val style = spanStyleRange.item - +private fun Spannable.setSpanStyle(style: SpanStyle, start: Int, end: Int, density: Density) { // Be aware that SuperscriptSpan needs to be applied before all other spans which // affect FontMetrics setBaselineShift(style.baselineShift, start, end) @@ -269,19 +262,23 @@ private fun Spannable.setSpanStyle( * [0, 8). The resolved TypefaceSpan should be TypefaceSpan("Sans-serif", "bold") in range [0, 8). As demonstrated above, the fontFamily information is from [contextTextStyle]. * * @param contextTextStyle the global [TextStyle] for the entire string. - * @param spanStyles the [spanStyles] to be applied, this function will first filter out the font + * @param annotations the [annotations] to be applied, this function will first filter out the font * related [SpanStyle]s and then apply them to this [Spannable]. - * @param fontFamilyResolver the [Font.ResourceLoader] used to resolve font. + * @param resolveTypeface the lambda used to resolve font. * @see flattenFontStylesAndApply */ -@OptIn(InternalPlatformTextApi::class) private fun Spannable.setFontAttributes( contextTextStyle: TextStyle, - spanStyles: List>, + annotations: List>, resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface, ) { + @Suppress("UNCHECKED_CAST") val fontRelatedSpanStyles = - spanStyles.fastFilter { it.item.hasFontAttributes() || it.item.fontSynthesis != null } + annotations.fastFilteredMap({ + it.item is SpanStyle && (it.item.hasFontAttributes() || it.item.fontSynthesis != null) + }) { + it as AnnotatedString.Range + } // Create a SpanStyle if contextTextStyle has font related attributes, otherwise use // null to avoid unnecessary object creation. diff --git a/compose/ui/ui-text/src/androidUnitTest/kotlin/androidx/compose/ui/text/AnnotatedStringTest.kt b/compose/ui/ui-text/src/androidUnitTest/kotlin/androidx/compose/ui/text/AnnotatedStringTest.kt index c356b99edd8f0..fb6d543e14eb6 100644 --- a/compose/ui/ui-text/src/androidUnitTest/kotlin/androidx/compose/ui/text/AnnotatedStringTest.kt +++ b/compose/ui/ui-text/src/androidUnitTest/kotlin/androidx/compose/ui/text/AnnotatedStringTest.kt @@ -18,7 +18,11 @@ package androidx.compose.ui.text import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString.Range +import androidx.compose.ui.text.style.Hyphens +import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.sp import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -27,6 +31,284 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class AnnotatedStringTest { + + val par1 = ParagraphStyle(textIndent = TextIndent(10.sp)) + val par2 = ParagraphStyle(hyphens = Hyphens.Auto) + val par3 = ParagraphStyle(textDirection = TextDirection.Rtl) + val par4 = ParagraphStyle(lineBreak = LineBreak.Simple) + + @Test + fun normalizedParagraphStyles_nested() { + val testString = buildAnnotatedString { + withStyle(par1) { + append("a") + withStyle(par2) { append("b") } + append("c") + } + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + assertThat(paragraphs) + .isEqualTo(listOf(Range(par1, 0, 1), Range(par1.merge(par2), 1, 2), Range(par1, 2, 3))) + } + + @Test + fun normalizedParagraphStyles_overlapped() { + val testString = buildAnnotatedString { + append("1234") + addStyle(par1, 0, 4) + addStyle(par2, 0, 2) + } + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + + assertThat(paragraphs).isEqualTo(listOf(Range(par1.merge(par2), 0, 2), Range(par1, 2, 4))) + } + + @Test + fun normalizedParagraphStyles_stackCorrectlyCleared() { + val testString = buildAnnotatedString { + append("0") + withStyle(par1) { + append("a") + withStyle(par2) { append("b") } + append("c") + } + withStyle(par3) { append("f") } + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + assertThat(paragraphs) + .isEqualTo( + listOf( + Range(ParagraphStyle(), 0, 1), + Range(par1, 1, 2), + Range(par1.merge(par2), 2, 3), + Range(par1, 3, 4), + Range(par3, 4, 5) + ) + ) + } + + @Test + fun normalizedParagraphStyles_stackCorrectlyCleared_whenEndsOverlap() { + val testString = buildAnnotatedString { + withStyle(par1) { + append("a") + withStyle(par2) { append("b") } + } // check this is cleared correctly + withStyle(par3) { append("c") } + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + assertThat(paragraphs) + .isEqualTo(listOf(Range(par1, 0, 1), Range(par1.merge(par2), 1, 2), Range(par3, 2, 3))) + } + + @Test + fun normalizedParagraphStyles_fullyOverlapped() { + val testString = buildAnnotatedString { + withStyle(par1) { withStyle(par2) { append("a") } } + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + assertThat(paragraphs) + .isEqualTo( + listOf( + Range(par1.merge(par2), 0, 1), + ) + ) + } + + @Test + fun normalizedParagraphStyles_fullyOverlapped_stackCorrectlyCleared() { + val testString = buildAnnotatedString { + withStyle(par1) { withStyle(par2) { append("a") } } + withStyle(par3) { append("b") } + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + assertThat(paragraphs).isEqualTo(listOf(Range(par1.merge(par2), 0, 1), Range(par3, 1, 2))) + } + + @Test + fun normalizedParagraphStyles_complex_withNoParagraphsInBetween() { + val testString = buildAnnotatedString { + append("text1") // 0-5 + withStyle(par1) { + append("text2") // 5-10 + withStyle(par2) { + append("text3") // 10-15 + } + } + append("text4") // 15-20 + withStyle(par3) { + append("text5") // 20-25 + } + append("text6") // 25-30 + } + + val default = ParagraphStyle() + val paragraphs = testString.normalizedParagraphStyles(default) + + assertThat(paragraphs) + .isEqualTo( + listOf( + Range(default, 0, 5), + Range(par1, 5, 10), + Range(par1.merge(par2), 10, 15), + Range(default, 15, 20), + Range(par3, 20, 25), + Range(default, 25, 30) + ) + ) + } + + @Test + fun normalizedParagraphStyles_complex_nestedSiblingParagraphs() { + val testString = buildAnnotatedString { + append("text1") // 0-5 + withStyle(par1) { + append("text2") // 5-10 + withStyle(par2) { + append("text3") // 10-15 + } + withStyle(par3) { + append("text4") // 15-20 + } + append("text5") // 20-25 + } + } + + val default = ParagraphStyle() + val paragraphs = testString.normalizedParagraphStyles(default) + + assertThat(paragraphs) + .isEqualTo( + listOf( + Range(default, 0, 5), + Range(par1, 5, 10), + Range(par1.merge(par2), 10, 15), + Range(par1.merge(par3), 15, 20), + Range(par1, 20, 25), + ) + ) + } + + @Test + fun normalizedParagraphStyle_withBlankLinesAround() { + val testString = buildAnnotatedString { + pushStyle(par1) + append("") + pop() + pushStyle(par2) + append("a") + pop() + pushStyle(par3) + append("") + pop() + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + + assertThat(paragraphs) + .isEqualTo(listOf(Range(par1, 0, 0), Range(par2, 0, 1), Range(par3, 1, 1))) + } + + @Test + fun normalizedParagraphStyle_withBlankLinesAtEnd() { + val testString = buildAnnotatedString { + pushStyle(par1) + append("a") + pop() + pushStyle(par2) + append("") + pop() + pushStyle(par3) + append("") + pop() + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + + assertThat(paragraphs).isEqualTo(listOf(Range(par1, 0, 1), Range(par2.merge(par3), 1, 1))) + } + + @Test + fun normalizedParagraphStyle_withBlankLines_correctlyClearsStack() { + val testString = buildAnnotatedString { + withStyle(par1) { + append("") + withStyle(par2) { append("") } + append("") + } + append("a") + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + + assertThat(paragraphs) + .isEqualTo(listOf(Range(par1.merge(par2), 0, 0), Range(ParagraphStyle(), 0, 1))) + } + + @Test + fun normalizedParagraphStyles_multiLevelNested() { + val testString = buildAnnotatedString { + append("text1") // 0-5 + withStyle(par1) { + append("text2") // 5-10 + withStyle(par2) { + withStyle(par3) { + append("text3") // 10-15 + withStyle(par4) { + append("text4") // 15-20 + } + } + } + append("text5") // 20-25 + } + } + + val default = ParagraphStyle() + val paragraphs = testString.normalizedParagraphStyles(default) + + assertThat(paragraphs) + .isEqualTo( + listOf( + Range(default, 0, 5), + Range(par1, 5, 10), + Range(par1.merge(par2).merge(par3), 10, 15), + Range(par1.merge(par2).merge(par3).merge(par4), 15, 20), + Range(par1, 20, 25), + ) + ) + } + + @Test + fun normalizedParagraphStyles_zeroLengthParagraph_atStart() { + val testString = buildAnnotatedString { + withStyle(par1) {} + append("0") + withStyle(par2) { append("a") } + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + assertThat(paragraphs) + .isEqualTo(listOf(Range(par1, 0, 0), Range(ParagraphStyle(), 0, 1), Range(par2, 1, 2))) + } + + @Test + fun normalizedParagraphStyles_zeroLengthParagraph() { + val testString = buildAnnotatedString { + append("0") + withStyle(par1) {} + withStyle(par2) { append("a") } + } + + val paragraphs = testString.normalizedParagraphStyles(ParagraphStyle()) + assertThat(paragraphs) + .isEqualTo(listOf(Range(ParagraphStyle(), 0, 1), Range(par1, 1, 1), Range(par2, 1, 2))) + } + @Test fun normalizedParagraphStyles() { val text = "Hello World" @@ -68,6 +350,25 @@ class AnnotatedStringTest { assertThat(paragraphs).isEqualTo(listOf(Range(defaultParagraphStyle, 0, text.length))) } + @Test + fun normalizedParagraphStyles_zeroLength_paragraphStyle() { + val text = "a" + val par = ParagraphStyle(textDirection = TextDirection.Rtl) + val annotatedString = + AnnotatedString(text = text, paragraphStyles = listOf(Range(par, 0, 0))) + val defaultParagraphStyle = ParagraphStyle(lineHeight = 20.sp) + + val paragraphs = annotatedString.normalizedParagraphStyles(defaultParagraphStyle) + + assertThat(paragraphs) + .isEqualTo( + listOf( + Range(defaultParagraphStyle.merge(par), 0, 0), + Range(defaultParagraphStyle, 0, 1) + ) + ) + } + @Test fun normalizedParagraphStyles_with_newLine() { val text = "Hello\nWorld" @@ -517,11 +818,126 @@ class AnnotatedStringTest { } @Test(expected = IllegalArgumentException::class) - fun subSequence_throws_exception_for_overlapping_paragraphStyles_when_not_sorted() { + fun throws_exception_overlapping_exceedsLastMax() { + buildAnnotatedString { + append("12345") + addStyle(ParagraphStyle(), 1, 4) + addStyle(ParagraphStyle(), 2, 3) + addStyle(ParagraphStyle(), 3, 5) + } + } + + @Test(expected = IllegalArgumentException::class) + fun throws_exception_overlapping_addsCurrentToStack_whenEmptyStack() { + buildAnnotatedString { + append("12345") + addStyle(ParagraphStyle(), 1, 2) + addStyle(ParagraphStyle(), 2, 4) + addStyle(ParagraphStyle(), 3, 5) + } + } + + @Test + fun doesNot_throws_exceedsMax() { + buildAnnotatedString { + append("12345") + addStyle(ParagraphStyle(), 0, 3) + addStyle(ParagraphStyle(), 1, 2) + addStyle(ParagraphStyle(), 4, 5) + } + } + + @Test + fun doesNot_throws_stackCleared_insideMaxRange() { + buildAnnotatedString { + append("12345") + addStyle(ParagraphStyle(), 0, 3) + addStyle(ParagraphStyle(), 1, 2) + addStyle(ParagraphStyle(), 4, 5) + addStyle(ParagraphStyle(), 4, 4) + } + } + + @Test(expected = IllegalArgumentException::class) + fun throws_exception_overlapsDisallowedRange() { + buildAnnotatedString { + append("1234567890") + addStyle(ParagraphStyle(), 1, 10) + addStyle(ParagraphStyle(), 1, 8) + addStyle(ParagraphStyle(), 1, 6) + addStyle(ParagraphStyle(), 1, 4) + addStyle(ParagraphStyle(), 7, 9) + } + } + + @Test(expected = IllegalArgumentException::class) + fun throws_exception_overlapsDisallowedRange_first() { + buildAnnotatedString { + append("1234567890") + addStyle(ParagraphStyle(), 1, 10) + addStyle(ParagraphStyle(), 1, 8) + addStyle(ParagraphStyle(), 1, 6) + addStyle(ParagraphStyle(), 1, 4) + addStyle(ParagraphStyle(), 3, 5) + } + } + + @Test + fun doesNot_throw_fullyOverlapsDisallowedRange() { + buildAnnotatedString { + append("1234567890") + addStyle(ParagraphStyle(), 1, 10) + addStyle(ParagraphStyle(), 1, 8) + addStyle(ParagraphStyle(), 1, 6) + addStyle(ParagraphStyle(), 1, 4) + addStyle(ParagraphStyle(), 7, 8) + } + } + + @Test + fun doesNot_throw_insideAllowedRange() { + buildAnnotatedString { + append("1234567890") + addStyle(ParagraphStyle(), 1, 9) + addStyle(ParagraphStyle(), 2, 8) + addStyle(ParagraphStyle(), 3, 6) + addStyle(ParagraphStyle(), 4, 6) + } + } + + @Test(expected = IllegalArgumentException::class) + fun throws_exception_orderMatters_overlapping() { buildAnnotatedString { append("1234") - addStyle(ParagraphStyle(), 1, 3) - addStyle(ParagraphStyle(), 0, 2) + addStyle(par1, 0, 2) + addStyle(par2, 0, 4) + } + } + + @Test + fun doesNot_throw_exception_orderMatters_nested() { + buildAnnotatedString { + append("1234") + addStyle(par1, 0, 4) + addStyle(par2, 0, 2) + } + } + + @Test + fun doesNot_throw_exception_if_paragraphStyles_are_nested() { + buildAnnotatedString { + append("1234") + addStyle(ParagraphStyle(), 3, 4) + addStyle(ParagraphStyle(), 0, 4) + } + } + + @Test + fun doesNot_throw_exception_if_paragraphStyles_are_fully_overlapped() { + buildAnnotatedString { + append("1234") + addStyle(ParagraphStyle(), 3, 4) + addStyle(ParagraphStyle(), 3, 4) } } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt index d2e463d97a12e..f415d51eadb1f 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.text +import androidx.collection.mutableIntListOf import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.saveable.Saver @@ -23,14 +24,16 @@ import androidx.compose.ui.text.AnnotatedString.Annotation import androidx.compose.ui.text.AnnotatedString.Builder import androidx.compose.ui.text.AnnotatedString.Companion.equals import androidx.compose.ui.text.AnnotatedString.Range +import androidx.compose.ui.text.internal.checkPrecondition +import androidx.compose.ui.text.internal.requirePrecondition import androidx.compose.ui.text.intl.LocaleList import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastFilter +import androidx.compose.ui.util.fastFilteredMap import androidx.compose.ui.util.fastFlatMap import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract import kotlin.jvm.JvmName /** @@ -45,12 +48,12 @@ internal constructor(internal val annotations: List>?, val internal val spanStylesOrNull: List>? /** All [SpanStyle] that have been applied to a range of this String */ val spanStyles: List> - get() = spanStylesOrNull ?: emptyList() + get() = spanStylesOrNull ?: listOf() internal val paragraphStylesOrNull: List>? /** All [ParagraphStyle] that have been applied to a range of this String */ val paragraphStyles: List> - get() = paragraphStylesOrNull ?: emptyList() + get() = paragraphStylesOrNull ?: listOf() /** * The basic data structure of text with multiple styles. To construct an [AnnotatedString] you @@ -62,7 +65,7 @@ internal constructor(internal val annotations: List>?, val * @param spanStyles a list of [Range]s that specifies [SpanStyle]s on certain portion of the * text. These styles will be applied in the order of the list. And the [SpanStyle]s applied * later can override the former styles. Notice that [SpanStyle] attributes which are null or - * [Unspecified] won't change the current ones. + * unspecified won't change the current ones. * @param paragraphStyles a list of [Range]s that specifies [ParagraphStyle]s on certain portion * of the text. Each [ParagraphStyle] with a [Range] defines a paragraph of text. It's * required that [Range]s of paragraphs don't overlap with each other. If there are gaps @@ -90,9 +93,12 @@ internal constructor(internal val annotations: List>?, val * attributes of the last applied [SpanStyle] will override similar attributes of the * previously applied [SpanStyle]s. * - [SpanStyle] attributes which are null or Unspecified won't change the styling. - * - It's required that [Range]s of [ParagraphStyle]s don't overlap with each other. If there - * are gaps between specified paragraph [Range]s, a default paragraph will be created in - * between. + * - If there are gaps between specified paragraph [Range]s, a default paragraph will be created + * in between. + * - The paragraph [Range]s can't partially overlap. They must either not overlap at all, be + * nested (when inner paragraph's range is fully within the range of the outer paragraph) or + * fully overlap (when ranges of two paragraph are the same). For more details check the + * [AnnotatedString.Builder.addStyle] documentation. * * @throws IllegalArgumentException if [ParagraphStyle]s contains any two overlapping [Range]s. * @sample androidx.compose.ui.text.samples.AnnotatedStringMainConstructorSample @@ -100,7 +106,7 @@ internal constructor(internal val annotations: List>?, val */ constructor( text: String, - annotations: List> = emptyList() + annotations: List> = listOf() ) : this(annotations.ifEmpty { null }, text) init { @@ -123,20 +129,31 @@ internal constructor(internal val annotations: List>?, val spanStylesOrNull = spanStyles paragraphStylesOrNull = paragraphStyles - var lastStyleEnd = -1 - @Suppress("ListIterator") - paragraphStylesOrNull - ?.sortedBy { it.start } - ?.fastForEach { paragraphStyle -> - require(paragraphStyle.start >= lastStyleEnd) { - "ParagraphStyle should not overlap" + @Suppress("ListIterator") val sorted = paragraphStylesOrNull?.sortedBy { it.start } + if (!sorted.isNullOrEmpty()) { + val previousEnds = mutableIntListOf(sorted.first().end) + for (i in 1 until sorted.size) { + val current = sorted[i] + // [*************************************]..... + // ..[******].................................. + // ................[***************]........... + // ..................[******].................. + // current can only be one of these relatively to previous (start/end inclusive) + // ................... [**]...[**]...[**]..[**] + while (previousEnds.isNotEmpty()) { + val previousEnd = previousEnds.last() + if (current.start >= previousEnd) { + previousEnds.removeAt(previousEnds.lastIndex) + } else { + requirePrecondition(current.end <= previousEnd) { + "Paragraph overlap not allowed, end ${current.end} should be less than or equal to $previousEnd" + } + break + } } - require(paragraphStyle.end <= text.length) { - "ParagraphStyle range [${paragraphStyle.start}, ${paragraphStyle.end})" + - " is out of boundary" - } - lastStyleEnd = paragraphStyle.end + previousEnds.add(current.end) } + } } override val length: Int @@ -152,7 +169,7 @@ internal constructor(internal val annotations: List>?, val * @param endIndex the exclusive end offset of the range */ override fun subSequence(startIndex: Int, endIndex: Int): AnnotatedString { - require(startIndex <= endIndex) { + requirePrecondition(startIndex <= endIndex) { "start ($startIndex) should be less or equal to end ($endIndex)" } if (startIndex == 0 && endIndex == text.length) return this @@ -194,13 +211,13 @@ internal constructor(internal val annotations: List>?, val * with the range [start, end) will be returned. When [start] is bigger than [end], an empty * list will be returned. */ - @Suppress("UNCHECKED_CAST") + @Suppress("UNCHECKED_CAST", "KotlinRedundantDiagnosticSuppress") fun getStringAnnotations(tag: String, start: Int, end: Int): List> = - (annotations?.fastFilterMap({ + (annotations?.fastFilteredMap({ it.item is StringAnnotation && tag == it.tag && intersect(start, end, it.start, it.end) }) { it.unbox() - } ?: emptyList()) + } ?: listOf()) /** * Returns true if [getStringAnnotations] with the same parameters would return a non-empty list @@ -219,13 +236,13 @@ internal constructor(internal val annotations: List>?, val * with the range [start, end) will be returned. When [start] is bigger than [end], an empty * list will be returned. */ - @Suppress("UNCHECKED_CAST") + @Suppress("UNCHECKED_CAST", "KotlinRedundantDiagnosticSuppress") fun getStringAnnotations(start: Int, end: Int): List> = - annotations?.fastFilterMap({ + annotations?.fastFilteredMap({ it.item is StringAnnotation && intersect(start, end, it.start, it.end) }) { it.unbox() - } ?: emptyList() + } ?: listOf() /** * Query all of the [TtsAnnotation]s attached on this [AnnotatedString]. @@ -240,7 +257,7 @@ internal constructor(internal val annotations: List>?, val fun getTtsAnnotations(start: Int, end: Int): List> = ((annotations?.fastFilter { it.item is TtsAnnotation && intersect(start, end, it.start, it.end) - } ?: emptyList()) + } ?: listOf()) as List>) /** @@ -252,13 +269,12 @@ internal constructor(internal val annotations: List>?, val * with the range [start, end) will be returned. When [start] is bigger than [end], an empty * list will be returned. */ - @ExperimentalTextApi @Suppress("UNCHECKED_CAST", "Deprecation") @Deprecated("Use LinkAnnotation API instead", ReplaceWith("getLinkAnnotations(start, end)")) fun getUrlAnnotations(start: Int, end: Int): List> = ((annotations?.fastFilter { it.item is UrlAnnotation && intersect(start, end, it.start, it.end) - } ?: emptyList()) + } ?: listOf()) as List>) /** @@ -274,7 +290,7 @@ internal constructor(internal val annotations: List>?, val fun getLinkAnnotations(start: Int, end: Int): List> = ((annotations?.fastFilter { it.item is LinkAnnotation && intersect(start, end, it.start, it.end) - } ?: emptyList()) + } ?: listOf()) as List>) /** @@ -333,7 +349,7 @@ internal constructor(internal val annotations: List>?, val } /** - * Returns a new [AnnotatedString] where a list of annotations contains all elementes yielded + * Returns a new [AnnotatedString] where a list of annotations contains all elements yielded * from results [transform] function being invoked on each element of original annotations list. * * @see mapAnnotations @@ -361,7 +377,7 @@ internal constructor(internal val annotations: List>?, val constructor(item: T, start: Int, end: Int) : this(item, start, end, "") init { - require(start <= end) { "Reversed range is not supported" } + requirePrecondition(start <= end) { "Reversed range is not supported" } } } @@ -392,7 +408,7 @@ internal constructor(internal val annotations: List>?, val */ fun toRange(defaultEnd: Int = Int.MIN_VALUE): Range { val end = if (end == Int.MIN_VALUE) defaultEnd else end - check(end != Int.MIN_VALUE) { "Item.end should be set first" } + checkPrecondition(end != Int.MIN_VALUE) { "Item.end should be set first" } return Range(item = item, start = start, end = end, tag = tag) } @@ -403,12 +419,12 @@ internal constructor(internal val annotations: List>?, val */ fun toRange(transform: (T) -> R, defaultEnd: Int = Int.MIN_VALUE): Range { val end = if (end == Int.MIN_VALUE) defaultEnd else end - check(end != Int.MIN_VALUE) { "Item.end should be set first" } + checkPrecondition(end != Int.MIN_VALUE) { "Item.end should be set first" } return Range(item = transform(item), start = start, end = end, tag = tag) } companion object { - fun fromRange(range: AnnotatedString.Range) = + fun fromRange(range: Range) = MutableRange(range.item, range.start, range.end, range.tag) } } @@ -546,7 +562,7 @@ internal constructor(internal val annotations: List>?, val } /** - * Set a [SpanStyle] for the given [range]. + * Set a [SpanStyle] for the given range defined by [start] and [end]. * * @param style [SpanStyle] to be applied * @param start the inclusive starting offset of the range @@ -557,8 +573,40 @@ internal constructor(internal val annotations: List>?, val } /** - * Set a [ParagraphStyle] for the given [range]. When a [ParagraphStyle] is applied to the - * [AnnotatedString], it will be rendered as a separate paragraph. + * Set a [ParagraphStyle] for the given range defined by [start] and [end]. When a + * [ParagraphStyle] is applied to the [AnnotatedString], it will be rendered as a separate + * paragraph. + * + * **Paragraphs arrangement** + * + * AnnotatedString only supports a few ways that arrangements can be arranged. + * + * The () and {} below represent different [ParagraphStyle]s passed in that particular order + * to the AnnotatedString. + * * **Non-overlapping:** paragraphs don't affect each other. Example: (abc){def} or + * abc(def)ghi{jkl}. + * * **Nested:** one paragraph is completely inside the other. Example: (abc{def}ghi) or + * ({abc}def) or (abd{def}). Note that because () is passed before {} to the + * AnnotatedString, these are considered nested. + * * **Fully overlapping:** two paragraphs cover the exact same range of text. Example: + * ({abc}). + * * **Overlapping:** one paragraph partially overlaps the other. Note that this is invalid! + * Example: (abc{de)f}. + * + * The order in which you apply `ParagraphStyle` can affect how the paragraphs are arranged. + * For example, when you first add () at range 0..4 and then {} at range 0..2, this + * paragraphs arrangement is considered nested. But if you first add a () paragraph at range + * 0..2 and then {} at range 0..4, this arrangement is considered overlapping and is + * invalid. + * + * **Styling** + * + * If you don't pass a paragraph style for any part of the text, a paragraph will be created + * anyway with a default style. In case of nested paragraphs, the outer paragraph will be + * split on the bounds of inner paragraph when the paragraphs are passed to be measured and + * rendered. For example, (abc{def}ghi) will be split into (abc)({def})(ghi). The inner + * paragraph, similarly to fully overlapping paragraphs, will have a style that is a + * combination of two created using a [ParagraphStyle.merge] method. * * @param style [ParagraphStyle] to be applied * @param start the inclusive starting offset of the range @@ -569,7 +617,7 @@ internal constructor(internal val annotations: List>?, val } /** - * Set an Annotation for the given [range]. + * Set an Annotation for the given range defined by [start] and [end]. * * @param tag the tag used to distinguish annotations * @param annotation the string annotation that is attached @@ -590,7 +638,7 @@ internal constructor(internal val annotations: List>?, val } /** - * Set a [TtsAnnotation] for the given [range]. + * Set a [TtsAnnotation] for the given range defined by [start] and [end]. * * @param ttsAnnotation an object that stores text to speech metadata that intended for the * TTS engine. @@ -599,16 +647,15 @@ internal constructor(internal val annotations: List>?, val * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample * @see getStringAnnotations */ - @ExperimentalTextApi @Suppress("SetterReturnsThis") fun addTtsAnnotation(ttsAnnotation: TtsAnnotation, start: Int, end: Int) { annotations.add(MutableRange(ttsAnnotation, start, end)) } /** - * Set a [UrlAnnotation] for the given [range]. URLs may be treated specially by screen - * readers, including being identified while reading text with an audio icon or being - * summarized in a links menu. + * Set a [UrlAnnotation] for the given range defined by [start] and [end]. URLs may be + * treated specially by screen readers, including being identified while reading text with + * an audio icon or being summarized in a links menu. * * @param urlAnnotation A [UrlAnnotation] object that stores the URL being linked to. * @param start the inclusive starting offset of the range @@ -616,7 +663,6 @@ internal constructor(internal val annotations: List>?, val * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample * @see getStringAnnotations */ - @ExperimentalTextApi @Suppress("SetterReturnsThis", "Deprecation") @Deprecated( "Use LinkAnnotation API for links instead", @@ -627,10 +673,10 @@ internal constructor(internal val annotations: List>?, val } /** - * Set a [LinkAnnotation.Url] for the given [range]. + * Set a [LinkAnnotation.Url] for the given range defined by [start] and [end]. * - * When clicking on the text in [range], the corresponding URL from the [url] annotation - * will be opened using [androidx.compose.ui.platform.UriHandler]. + * When clicking on the text in range, the corresponding URL from the [url] annotation will + * be opened using [androidx.compose.ui.platform.UriHandler]. * * URLs may be treated specially by screen readers, including being identified while reading * text with an audio icon or being summarized in a links menu. @@ -646,9 +692,9 @@ internal constructor(internal val annotations: List>?, val } /** - * Set a [LinkAnnotation.Clickable] for the given [range]. + * Set a [LinkAnnotation.Clickable] for the given range defined by [start] and [end]. * - * When clicking on the text in [range], a [LinkInteractionListener] will be triggered with + * When clicking on the text in range, a [LinkInteractionListener] will be triggered with * the [clickable] object. * * Clickable link may be treated specially by screen readers, including being identified @@ -738,7 +784,6 @@ internal constructor(internal val annotations: List>?, val * @see Range */ @Suppress("BuilderSetStyle", "Deprecation") - @ExperimentalTextApi @Deprecated( "Use LinkAnnotation API for links instead", ReplaceWith("pushLink(, start, end)") @@ -776,7 +821,7 @@ internal constructor(internal val annotations: List>?, val * @see pushStringAnnotation */ fun pop() { - check(styleStack.isNotEmpty()) { "Nothing to pop." } + checkPrecondition(styleStack.isNotEmpty()) { "Nothing to pop." } // pop the last element val item = styleStack.removeAt(styleStack.size - 1) item.end = text.length @@ -793,7 +838,9 @@ internal constructor(internal val annotations: List>?, val * @see pushStringAnnotation */ fun pop(index: Int) { - check(index < styleStack.size) { "$index should be less than ${styleStack.size}" } + checkPrecondition(index < styleStack.size) { + "$index should be less than ${styleStack.size}" + } while ((styleStack.size - 1) >= index) { pop() } @@ -820,8 +867,8 @@ internal constructor(internal val annotations: List>?, val transform: (Range) -> List> ) { val replacedAnnotations = - annotations.fastFlatMap { - transform(it.toRange()).fastMap { MutableRange.fromRange(it) } + annotations.fastFlatMap { annotation -> + transform(annotation.toRange()).fastMap { MutableRange.fromRange(it) } } annotations.clear() annotations.addAll(replacedAnnotations) @@ -843,7 +890,7 @@ internal constructor(internal val annotations: List>?, val sealed interface Annotation // Unused private subclass of the marker interface to avoid exhaustive "when" statement - private class ExhaustiveAnnotation : Annotation + @Suppress("unused") private class ExhaustiveAnnotation : Annotation companion object { /** @@ -881,9 +928,22 @@ private fun constructAnnotationsFromSpansAndParagraphs( * It reads paragraph information from [AnnotatedString.paragraphStyles] where only some parts of * text has [ParagraphStyle] specified, and unspecified parts(gaps between specified paragraphs) are * considered as default paragraph with default [ParagraphStyle]. For example, the following string - * with a specified paragraph denoted by "[]" "Hello WorldHi!" [ ] The result paragraphs are "Hello + * "(Hello World)Hi!" with a specified paragraph denoted by () will result in paragraphs "Hello * World" and "Hi!". * + * **Algorithm implementation** + * * Keep a stack of paragraphs that to be *fully* processed yet and a pointer to the end of last + * paragraph already added to the result. + * * Iterate through each paragraph. + * * Check if there's a gap between last added paragraph and start of current paragraph. If yes, we + * need to add text covered by it to the result, making sure to check the existing state of the + * stack to merge the styles correctly. + * * Add a paragraph to the stack. Depending on its range, we might need to merge its style with the + * latest one in the stack. + * * Along the way handle special cases like fully overlapped or zero-length paragraphs. + * * After the last iteration, clear the stack by adding additional paragraphs to the result. Also + * move the pointer to the end of the text. + * * @param defaultParagraphStyle The default [ParagraphStyle]. It's used for both unspecified default * paragraphs and specified paragraph. When a specified paragraph's [ParagraphStyle] has a null * attribute, the default one will be used instead. @@ -891,21 +951,99 @@ private fun constructAnnotationsFromSpansAndParagraphs( internal fun AnnotatedString.normalizedParagraphStyles( defaultParagraphStyle: ParagraphStyle ): List> { - val length = text.length - val paragraphStyles = paragraphStylesOrNull ?: emptyList() - - var lastOffset = 0 + @Suppress("ListIterator") + val sortedParagraphs = paragraphStylesOrNull?.sortedBy { it.start } ?: listOf() val result = mutableListOf>() - paragraphStyles.fastForEach { (style, start, end) -> - if (start != lastOffset) { - result.add(Range(defaultParagraphStyle, lastOffset, start)) + + // a pointer to the last character added to the result list, takes values from 0 to text.length + var lastAdded = 0 + val stack = ArrayDeque>() + + sortedParagraphs.fastForEach { + val current = it.copy(defaultParagraphStyle.merge(it.item)) + while (lastAdded < current.start && stack.isNotEmpty()) { + val lastInStack = stack.last() + // ..withStyle(A) { <-- last in stack.... + // ....append............................ + // ....withStyle(B) { <-- current........ + // ......append.......................... + // ....}................................. + // ..}................................... + if (current.start < lastInStack.end) { + result.add(Range(lastInStack.item, lastAdded, current.start)) + lastAdded = current.start + } else { + // ..withStyle(A) {...................... + // ....append............................ + // ....withStyle(B) { <-- last in stack.. + // ......append.......................... + // ....}................................. + // ..}................................... + // withStyle(C) <-- current + result.add(Range(lastInStack.item, lastAdded, lastInStack.end)) + lastAdded = lastInStack.end + // We now need to remove it from the stack but also make sure that we remove other + // stack + // entrances that have the same ends as the lastAdded + while (stack.isNotEmpty() && lastAdded == stack.last().end) { + stack.removeLast() + } + } + } + + if (lastAdded < current.start) { + result.add(Range(defaultParagraphStyle, lastAdded, current.start)) + lastAdded = current.start + } + + val lastInStack = stack.lastOrNull() + if (lastInStack != null) { + if (lastInStack.start == current.start && lastInStack.end == current.end) { + // fully overlapped, we'll merge current with the previous one and remove the + // previous one from the stack + stack.removeLast() + stack.add(Range(lastInStack.item.merge(current.item), current.start, current.end)) + } else if (lastInStack.start == lastInStack.end) { + // this is a zero-length paragraph + result.add(Range(lastInStack.item, lastInStack.start, lastInStack.end)) + stack.removeLast() + stack.add(Range(current.item, current.start, current.end)) + } else if (lastInStack.end < current.end) { + // This is already handled in the init require checks + throw IllegalArgumentException() + } else { + stack.add(Range(lastInStack.item.merge(current.item), current.start, current.end)) + } + } else { + stack.add(Range(current.item, current.start, current.end)) } - result.add(Range(defaultParagraphStyle.merge(style), start, end)) - lastOffset = end } - if (lastOffset != length) { - result.add(Range(defaultParagraphStyle, lastOffset, length)) + + // The paragraph styles finished so we need to empty the stack to add the remaining to the + // result + while (lastAdded <= text.length && stack.isNotEmpty()) { + // ..withStyle(A) {...................... + // ....append............................ + // ....withStyle(B) { <-- last in stack.. + // ......append.......................... + // ....}................................. + // ..}................................... + // ....End of AnnotatedString builder.... + val lastInStack = stack.last() + result.add(Range(lastInStack.item, lastAdded, lastInStack.end)) + lastAdded = lastInStack.end + // We now need to remove it from the stack but also make sure that we remove other stack + // entrances that have the same ends as the lastAdded + while (stack.isNotEmpty() && lastAdded == stack.last().end) { + stack.removeLast() + } + } + + // There might be a text left at the end that isn't covered with a paragraph so using a default + if (lastAdded < text.length) { + result.add(Range(defaultParagraphStyle, lastAdded, text.length)) } + // This is a corner case where annotatedString is an empty string without any ParagraphStyle. // In this case, an empty ParagraphStyle is created. if (result.isEmpty()) { @@ -914,26 +1052,6 @@ internal fun AnnotatedString.normalizedParagraphStyles( return result } -/** - * Helper function used to find the [SpanStyle]s in the given paragraph range and also convert the - * range of those [SpanStyle]s to paragraph local range. - * - * @param start The start index of the paragraph range, inclusive - * @param end The end index of the paragraph range, exclusive - * @return The list of converted [SpanStyle]s in the given paragraph range - */ -private fun AnnotatedString.getLocalSpanStyles(start: Int, end: Int): List>? { - if (start == end) return null - val spanStyles = spanStylesOrNull ?: return null - // If the given range covers the whole AnnotatedString, return SpanStyles without conversion. - if (start == 0 && end >= this.text.length) { - return spanStyles - } - return spanStyles.fastFilterMap({ intersect(start, end, it.start, it.end) }) { - Range(it.item, it.start.coerceIn(start, end) - start, it.end.coerceIn(start, end) - start) - } -} - /** * Helper function used to find the [ParagraphStyle]s in the given range and also convert the range * of those styles to the local range. @@ -951,29 +1069,38 @@ private fun AnnotatedString.getLocalParagraphStyles( if (start == 0 && end >= this.text.length) { return paragraphStyles } - return paragraphStyles.fastFilterMap({ intersect(start, end, it.start, it.end) }) { - Range(it.item, it.start.coerceIn(start, end) - start, it.end.coerceIn(start, end) - start) + return paragraphStyles.fastFilteredMap({ intersect(start, end, it.start, it.end) }) { + Range( + it.item, + it.start.fastCoerceIn(start, end) - start, + it.end.fastCoerceIn(start, end) - start + ) } } /** - * Helper function used to find the annotations in the given range and also convert the range of - * those annotations to the local range. - * - * @param start The start index of the range, inclusive - * @param end The end index of the range, exclusive + * Helper function used to find the annotations in the given range that match the [predicate], and + * also convert the range of those annotations to the local range. Null [predicate] means is similar + * to passing true. */ private fun AnnotatedString.getLocalAnnotations( start: Int, - end: Int + end: Int, + predicate: ((Annotation) -> Boolean)? = null ): List>? { if (start == end) return null val annotations = annotations ?: return null // If the given range covers the whole AnnotatedString, return it without conversion. if (start == 0 && end >= this.text.length) { - return annotations + return if (predicate == null) { + annotations + } else { + annotations.fastFilter { predicate(it.item) } + } } - return annotations.fastFilterMap({ intersect(start, end, it.start, it.end) }) { + return annotations.fastFilteredMap({ + (predicate?.invoke(it.item) ?: true) && intersect(start, end, it.start, it.end) + }) { Range( tag = it.tag, item = it.item, @@ -995,7 +1122,7 @@ private fun AnnotatedString.getLocalAnnotations( private fun AnnotatedString.substringWithoutParagraphStyles(start: Int, end: Int): AnnotatedString { return AnnotatedString( text = if (start != end) text.substring(start, end) else "", - annotations = getLocalSpanStyles(start, end) ?: emptyList() + annotations = getLocalAnnotations(start, end) { it !is ParagraphStyle } ?: listOf() ) } @@ -1159,7 +1286,6 @@ inline fun Builder.withStyle( * @see AnnotatedString.Builder.pushStringAnnotation * @see AnnotatedString.Builder.pop */ -@ExperimentalTextApi inline fun Builder.withAnnotation( tag: String, annotation: String, @@ -1184,7 +1310,6 @@ inline fun Builder.withAnnotation( * @see AnnotatedString.Builder.pushStringAnnotation * @see AnnotatedString.Builder.pop */ -@ExperimentalTextApi inline fun Builder.withAnnotation( ttsAnnotation: TtsAnnotation, crossinline block: Builder.() -> R @@ -1207,7 +1332,6 @@ inline fun Builder.withAnnotation( * @see AnnotatedString.Builder.pushStringAnnotation * @see AnnotatedString.Builder.pop */ -@ExperimentalTextApi @Deprecated("Use LinkAnnotation API for links instead", ReplaceWith("withLink(, block)")) @Suppress("Deprecation") inline fun Builder.withAnnotation( @@ -1250,11 +1374,13 @@ inline fun Builder.withLink(link: LinkAnnotation, block: Builder.() -> * @param end the exclusive end offset of the text range */ private fun filterRanges(ranges: List>?, start: Int, end: Int): List>? { - require(start <= end) { "start ($start) should be less than or equal to end ($end)" } + requirePrecondition(start <= end) { + "start ($start) should be less than or equal to end ($end)" + } val nonNullRange = ranges ?: return null return nonNullRange - .fastFilterMap({ intersect(start, end, it.start, it.end) }) { + .fastFilteredMap({ intersect(start, end, it.start, it.end) }) { Range( item = it.item, start = maxOf(start, it.start) - start, @@ -1317,27 +1443,26 @@ internal fun contains(baseStart: Int, baseEnd: Int, targetStart: Int, targetEnd: * * @return [lStart, lEnd) intersects with range [rStart, rEnd), vice versa. */ -internal fun intersect(lStart: Int, lEnd: Int, rStart: Int, rEnd: Int) = - maxOf(lStart, rStart) < minOf(lEnd, rEnd) || - contains(lStart, lEnd, rStart, rEnd) || - contains(rStart, rEnd, lStart, lEnd) +internal fun intersect(lStart: Int, lEnd: Int, rStart: Int, rEnd: Int): Boolean { + // We can check if two ranges intersect just by performing the following operation: + // + // lStart < rEnd && rStart < lEnd + // + // This operation handles all cases, including when one of the ranges is fully included in the + // other ranges. This is however not enough in this particular case because our ranges are open + // at the end, but closed at the start. + // + // This means the test above would fail cases like: [1, 4) intersect [1, 1) + // To address this we check if either one of the ranges is a "point" (empty selection). If + // that's the case and both ranges share the same start point, then they intersect. + // + // In addition, we use bitwise operators (or, and) instead of boolean operators (||, &&) to + // generate branchless code. + return ((lStart == lEnd) or (rStart == rEnd) and (lStart == rStart)) or + ((lStart < rEnd) and (rStart < lEnd)) +} private val EmptyAnnotatedString: AnnotatedString = AnnotatedString("") /** Returns an AnnotatedString with empty text and no annotations. */ internal fun emptyAnnotatedString() = EmptyAnnotatedString - -@OptIn(ExperimentalContracts::class) -@Suppress("BanInlineOptIn") -private inline fun List.fastFilterMap( - predicate: (T) -> Boolean, - transform: (T) -> R -): List { - contract { - callsInPlace(predicate) - callsInPlace(transform) - } - val target = ArrayList(size) - fastForEach { if (predicate(it)) target += transform(it) } - return target -} diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt index fff373a66e06e..5bb0c6ef9934d 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.DrawStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.text.internal.requirePrecondition import androidx.compose.ui.text.platform.drawMultiParagraph import androidx.compose.ui.text.style.ResolvedTextDirection import androidx.compose.ui.text.style.TextDecoration @@ -38,6 +39,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastFlatMap import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastJoinToString import androidx.compose.ui.util.fastMap /** @@ -71,12 +73,15 @@ class MultiParagraph( * @param maxLines the maximum number of lines that the text can have * @param ellipsis whether to ellipsize text, applied only when [maxLines] is set */ - @Deprecated("Constructor with `ellipsis: Boolean` is deprecated, pass TextOverflow instead ") + @Deprecated( + "Constructor with `ellipsis: Boolean` is deprecated, pass TextOverflow instead", + level = DeprecationLevel.HIDDEN + ) constructor( intrinsics: MultiParagraphIntrinsics, constraints: Constraints, maxLines: Int = DefaultMaxLines, - ellipsis: Boolean, + ellipsis: Boolean = false, ) : this( intrinsics = intrinsics, constraints = constraints, @@ -161,7 +166,7 @@ class MultiParagraph( fontFamilyResolver = createFontFamilyResolver(resourceLoader) ), maxLines = maxLines, - ellipsis = ellipsis, + overflow = if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip, constraints = Constraints(maxWidth = width.ceilToInt()) ) @@ -238,7 +243,10 @@ class MultiParagraph( * [placeholders] crosses paragraph boundary. * @see Placeholder */ - @Deprecated("Constructor with `ellipsis: Boolean` is deprecated, pass TextOverflow instead") + @Deprecated( + "Constructor with `ellipsis: Boolean` is deprecated, pass TextOverflow instead", + level = DeprecationLevel.HIDDEN + ) constructor( annotatedString: AnnotatedString, style: TextStyle, @@ -247,7 +255,7 @@ class MultiParagraph( fontFamilyResolver: FontFamily.Resolver, placeholders: List> = listOf(), maxLines: Int = Int.MAX_VALUE, - ellipsis: Boolean + ellipsis: Boolean = false ) : this( intrinsics = MultiParagraphIntrinsics( @@ -378,7 +386,7 @@ class MultiParagraph( internal val paragraphInfoList: List init { - require(constraints.minWidth == 0 && constraints.minHeight == 0) { + requirePrecondition(constraints.minWidth == 0 && constraints.minHeight == 0) { "Setting Constraints.minWidth and Constraints.minHeight is not supported, " + "these should be the default zero values instead." } @@ -511,7 +519,7 @@ class MultiParagraph( /** Returns path that enclose the given text range. */ fun getPathForRange(start: Int, end: Int): Path { - require(start in 0..end && end <= annotatedString.text.length) { + requirePrecondition(start in 0..end && end <= annotatedString.text.length) { "Start($start) or End($end) is out of range [0..${annotatedString.text.length})," + " or start > end!" } @@ -977,19 +985,19 @@ class MultiParagraph( } private fun requireIndexInRange(offset: Int) { - require(offset in annotatedString.text.indices) { + requirePrecondition(offset in annotatedString.text.indices) { "offset($offset) is out of bounds [0, ${annotatedString.length})" } } private fun requireIndexInRangeInclusiveEnd(offset: Int) { - require(offset in 0..annotatedString.text.length) { + requirePrecondition(offset in 0..annotatedString.text.length) { "offset($offset) is out of bounds [0, ${annotatedString.length}]" } } private fun requireLineIndexInRange(lineIndex: Int) { - require(lineIndex in 0 until lineCount) { + requirePrecondition(lineIndex in 0 until lineCount) { "lineIndex($lineIndex) is out of bounds [0, $lineCount)" } } @@ -1006,13 +1014,19 @@ class MultiParagraph( * @return The index of the target [ParagraphInfo] in [paragraphInfoList]. */ internal fun findParagraphByIndex(paragraphInfoList: List, index: Int): Int { - return paragraphInfoList.fastBinarySearch { paragraphInfo -> - when { - paragraphInfo.startIndex > index -> 1 - paragraphInfo.endIndex <= index -> -1 - else -> 0 + val paragraphIndex = + paragraphInfoList.fastBinarySearch { paragraphInfo -> + when { + paragraphInfo.startIndex > index -> 1 + paragraphInfo.endIndex <= index -> -1 + else -> 0 + } } + requirePrecondition(paragraphIndex in paragraphInfoList.indices) { + "Found paragraph index $paragraphIndex should be in range [0, ${paragraphInfoList.size}).\n" + + "Debug info: index=$index, paragraphs=[${paragraphInfoList.fastJoinToString { "[${it.startIndex}, ${it.endIndex})" }}]" } + return paragraphIndex } /** diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt index 2c35bcb846459..a8e5a772cc502 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt @@ -19,11 +19,11 @@ package androidx.compose.ui.text import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.text.internal.requirePrecondition import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastAny -import androidx.compose.ui.util.fastFilter -import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastFilteredMap import androidx.compose.ui.util.fastMaxBy /** @@ -106,7 +106,7 @@ class MultiParagraphIntrinsics( ParagraphIntrinsics( text = annotatedString.text, style = style.merge(currentParagraphStyle), - spanStyles = annotatedString.spanStyles, + annotations = annotatedString.annotations ?: emptyList(), placeholders = placeholders.getLocalPlaceholders( paragraphStyleItem.start, @@ -142,13 +142,12 @@ class MultiParagraphIntrinsics( } private fun List>.getLocalPlaceholders(start: Int, end: Int) = - fastFilter { intersect(start, end, it.start, it.end) } - .fastMap { - require(start <= it.start && it.end <= end) { - "placeholder can not overlap with paragraph." - } - AnnotatedString.Range(it.item, it.start - start, it.end - start) + fastFilteredMap({ intersect(start, end, it.start, it.end) }) { + requirePrecondition(start <= it.start && it.end <= end) { + "placeholder can not overlap with paragraph." } + AnnotatedString.Range(it.item, it.start - start, it.end - start) + } internal data class ParagraphIntrinsicInfo( val intrinsics: ParagraphIntrinsics, diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Paragraph.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Paragraph.kt index 36ba20ce69c51..4a852ab095cf4 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Paragraph.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Paragraph.kt @@ -500,7 +500,10 @@ fun Paragraph( * @param ellipsis whether to ellipsize text, applied only when [maxLines] is set * @throws IllegalArgumentException if [ParagraphStyle.textDirection] is not set */ -@Deprecated("Paragraph that takes `ellipsis: Boolean` is deprecated, pass TextOverflow instead.") +@Deprecated( + "Paragraph that takes `ellipsis: Boolean` is deprecated, pass TextOverflow instead.", + level = DeprecationLevel.HIDDEN +) fun Paragraph( text: String, style: TextStyle, @@ -510,7 +513,7 @@ fun Paragraph( spanStyles: List> = listOf(), placeholders: List> = listOf(), maxLines: Int = DefaultMaxLines, - ellipsis: Boolean + ellipsis: Boolean = false ): Paragraph = ActualParagraph( text, @@ -615,13 +618,14 @@ fun Paragraph( ReplaceWith( "Paragraph(paragraphIntrinsics, constraints, maxLines, " + "if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip" - ) + ), + level = DeprecationLevel.HIDDEN ) fun Paragraph( paragraphIntrinsics: ParagraphIntrinsics, constraints: Constraints, maxLines: Int = DefaultMaxLines, - ellipsis: Boolean + ellipsis: Boolean = false ): Paragraph = ActualParagraph( paragraphIntrinsics, diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphIntrinsics.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphIntrinsics.kt index 1a8c4b3e6b5a9..43f6b18ca13c2 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphIntrinsics.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphIntrinsics.kt @@ -78,12 +78,18 @@ fun ParagraphIntrinsics( ActualParagraphIntrinsics( text = text, style = style, - spanStyles = spanStyles, + annotations = spanStyles, placeholders = placeholders, density = density, fontFamilyResolver = createFontFamilyResolver(resourceLoader) ) +@Deprecated( + "Use an overload that takes `annotations` instead", + ReplaceWith( + "ParagraphIntrinsics(text, style, spanStyles, density, fontFamilyResolver, placeholders)" + ) +) fun ParagraphIntrinsics( text: String, style: TextStyle, @@ -95,7 +101,32 @@ fun ParagraphIntrinsics( ActualParagraphIntrinsics( text = text, style = style, - spanStyles = spanStyles, + annotations = spanStyles, + placeholders = placeholders, + density = density, + fontFamilyResolver = fontFamilyResolver + ) + +/** + * Factory method to create a [ParagraphIntrinsics]. + * + * If the [style] does not contain any [androidx.compose.ui.text.style.TextDirection], + * [androidx.compose.ui.text.style.TextDirection.Content] is used as the default value. + * + * @see ParagraphIntrinsics + */ +fun ParagraphIntrinsics( + text: String, + style: TextStyle, + annotations: List>, + density: Density, + fontFamilyResolver: FontFamily.Resolver, + placeholders: List> = listOf() +): ParagraphIntrinsics = + ActualParagraphIntrinsics( + text = text, + style = style, + annotations = annotations, placeholders = placeholders, density = density, fontFamilyResolver = fontFamilyResolver diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt index 1efdc5d163784..218e3ffb02f1f 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.text import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.ui.text.internal.checkPrecondition import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.LineHeightStyle @@ -204,7 +205,9 @@ class ParagraphStyle( init { if (lineHeight != TextUnit.Unspecified) { // Since we are checking if it's negative, no need to convert Sp into Px at this point. - check(lineHeight.value >= 0f) { "lineHeight can't be negative (${lineHeight.value})" } + checkPrecondition(lineHeight.value >= 0f) { + "lineHeight can't be negative (${lineHeight.value})" + } } } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt index 4afec709cc59b..a7fe2c5057eab 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.text import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.internal.requirePrecondition import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isUnspecified @@ -40,8 +41,8 @@ class Placeholder( val placeholderVerticalAlign: PlaceholderVerticalAlign ) { init { - require(!width.isUnspecified) { "width cannot be TextUnit.Unspecified" } - require(!height.isUnspecified) { "height cannot be TextUnit.Unspecified" } + requirePrecondition(!width.isUnspecified) { "width cannot be TextUnit.Unspecified" } + requirePrecondition(!height.isUnspecified) { "height cannot be TextUnit.Unspecified" } } fun copy( diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextLayoutResult.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextLayoutResult.kt index 11cef1974affc..25c00f0f2ec3a 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextLayoutResult.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextLayoutResult.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.createFontFamilyResolver import androidx.compose.ui.text.font.toFontFamily import androidx.compose.ui.text.platform.SynchronizedObject -import androidx.compose.ui.text.platform.createSynchronizedObject +import androidx.compose.ui.text.platform.makeSynchronizedObject import androidx.compose.ui.text.platform.synchronized import androidx.compose.ui.text.style.ResolvedTextDirection import androidx.compose.ui.text.style.TextOverflow @@ -272,7 +272,7 @@ private constructor(private val fontFamilyResolver: FontFamily.Resolver) : Font. // call getFontResourceLoader, and evaluate if FontFamily.Resolver is being correctly cached // (via e.g. remember) var cache = mutableMapOf() - val lock: SynchronizedObject = createSynchronizedObject() + val lock: SynchronizedObject = makeSynchronizedObject() fun from(fontFamilyResolver: FontFamily.Resolver): Font.ResourceLoader { synchronized(lock) { diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt index cd65c9883bbb2..b35f66618af7a 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt @@ -144,7 +144,7 @@ class TextMeasurer( overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, - placeholders: List> = emptyList(), + placeholders: List> = listOf(), constraints: Constraints = Constraints(), layoutDirection: LayoutDirection = this.defaultLayoutDirection, density: Density = this.defaultDensity, diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt index 4ef4dadb8cad2..cd3885eb10687 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt @@ -148,7 +148,7 @@ fun DrawScope.drawText( overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, - placeholders: List> = emptyList(), + placeholders: List> = listOf(), size: Size = Size.Unspecified, blendMode: BlendMode = DrawScope.DefaultBlendMode ) { diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt index 209d1f67d4d41..f9c3c0a179a41 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt @@ -17,9 +17,13 @@ package androidx.compose.ui.text import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.internal.requirePrecondition +import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.packInts import androidx.compose.ui.util.unpackInt1 import androidx.compose.ui.util.unpackInt2 +import kotlin.math.max +import kotlin.math.min fun CharSequence.substring(range: TextRange): String = this.substring(range.min, range.max) @@ -53,11 +57,11 @@ value class TextRange internal constructor(private val packedValue: Long) { /** The minimum offset of the range. */ val min: Int - get() = if (start > end) end else start + get() = min(start, end) /** The maximum offset of the range. */ val max: Int - get() = if (start > end) start else end + get() = max(start, end) /** Returns true if the range is collapsed */ val collapsed: Boolean @@ -72,10 +76,10 @@ value class TextRange internal constructor(private val packedValue: Long) { get() = max - min /** Returns true if the given range has intersection with this range */ - fun intersects(other: TextRange): Boolean = min < other.max && other.min < max + fun intersects(other: TextRange): Boolean = (min < other.max) and (other.min < max) /** Returns true if this range covers including equals with the given range. */ - operator fun contains(other: TextRange): Boolean = min <= other.min && other.max <= max + operator fun contains(other: TextRange): Boolean = (min <= other.min) and (other.max <= max) /** Returns true if the given offset is a part of this range. */ operator fun contains(offset: Int): Boolean = offset in min until max @@ -102,8 +106,8 @@ fun TextRange(index: Int): TextRange = TextRange(start = index, end = index) * @param maximumValue the exclusive maximum value that [TextRange.start] or [TextRange.end] can be. */ fun TextRange.coerceIn(minimumValue: Int, maximumValue: Int): TextRange { - val newStart = start.coerceIn(minimumValue, maximumValue) - val newEnd = end.coerceIn(minimumValue, maximumValue) + val newStart = start.fastCoerceIn(minimumValue, maximumValue) + val newEnd = end.fastCoerceIn(minimumValue, maximumValue) if (newStart != start || newEnd != end) { return TextRange(newStart, newEnd) } @@ -111,7 +115,8 @@ fun TextRange.coerceIn(minimumValue: Int, maximumValue: Int): TextRange { } private fun packWithCheck(start: Int, end: Int): Long { - require(start >= 0) { "start cannot be negative. [start: $start, end: $end]" } - require(end >= 0) { "end cannot be negative. [start: $start, end: $end]" } + requirePrecondition(start >= 0 && end >= 0) { + "start and end cannot be negative. [start: $start, end: $end]" + } return packInts(start, end) } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/UrlAnnotation.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/UrlAnnotation.kt index b1446a7d9827f..87d552a56ddaf 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/UrlAnnotation.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/UrlAnnotation.kt @@ -24,7 +24,6 @@ package androidx.compose.ui.text * Note: this is now deprecated. In order to display a link in the text, add a [LinkAnnotation] to * the AnnotatedString and pass it to the Text composable function */ -@ExperimentalTextApi @Deprecated("Use LinkAnnotatation.Url(url) instead", ReplaceWith("LinkAnnotation.Url(url)")) @Suppress("Deprecation") class UrlAnnotation(val url: String) : AnnotatedString.Annotation { diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt index b9953c15b19d0..fa98708986095 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.text.font import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.State +import androidx.compose.ui.text.internal.checkPrecondition /** * The primary typography interface for Compose applications. @@ -164,7 +165,7 @@ internal constructor( val fonts: List ) : FileBasedFontFamily(), List by fonts { init { - check(fonts.isNotEmpty()) { "At least one font should be passed to FontFamily" } + checkPrecondition(fonts.isNotEmpty()) { "At least one font should be passed to FontFamily" } } override fun equals(other: Any?): Boolean { diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.kt index 004da07829a2c..d9906721ea6f1 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.kt @@ -16,9 +16,9 @@ package androidx.compose.ui.text.font -import androidx.collection.SieveCache +import androidx.collection.LruCache import androidx.compose.runtime.State -import androidx.compose.ui.text.platform.createSynchronizedObject +import androidx.compose.ui.text.platform.makeSynchronizedObject import androidx.compose.ui.text.platform.synchronized import androidx.compose.ui.util.fastMap @@ -165,9 +165,9 @@ internal sealed interface TypefaceResult : State { } internal class TypefaceRequestCache { - internal val lock = createSynchronizedObject() + internal val lock = makeSynchronizedObject() // @GuardedBy("lock") - private val resultCache = SieveCache(16, 16) + private val resultCache = LruCache(16) fun runCached( typefaceRequest: TypefaceRequest, @@ -244,15 +244,15 @@ internal class TypefaceRequestCache { // has async fonts in permanent cache if (next is TypefaceResult.Async) continue - synchronized(lock) { resultCache[typeRequest] = next } + synchronized(lock) { resultCache.put(typeRequest, next) } } } // @VisibleForTesting internal fun get(typefaceRequest: TypefaceRequest) = - synchronized(lock) { resultCache[typefaceRequest] } + synchronized(lock) { resultCache.get(typefaceRequest) } // @VisibleForTesting internal val size: Int - get() = synchronized(lock) { resultCache.size } + get() = synchronized(lock) { resultCache.size() } } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt index 54c0aa1b5a160..0653f1a644282 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt @@ -16,17 +16,17 @@ package androidx.compose.ui.text.font -import androidx.collection.SieveCache +import androidx.collection.LruCache import androidx.collection.mutableScatterMapOf import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.platform.FontCacheManagementDispatcher -import androidx.compose.ui.text.platform.createSynchronizedObject +import androidx.compose.ui.text.platform.makeSynchronizedObject import androidx.compose.ui.text.platform.synchronized import androidx.compose.ui.util.fastDistinctBy -import androidx.compose.ui.util.fastFilter +import androidx.compose.ui.util.fastFilteredMap import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap import kotlin.coroutines.CoroutineContext @@ -68,8 +68,9 @@ internal class FontListFontFamilyTypefaceAdapter( // only preload styles that can be satisfied by async fonts val asyncStyles = family.fonts - .fastFilter { it.loadingStrategy == FontLoadingStrategy.Async } - .fastMap { it.weight to it.style } + .fastFilteredMap({ it.loadingStrategy == FontLoadingStrategy.Async }) { + it.weight to it.style + } .fastDistinctBy { it } val asyncLoads: MutableList = mutableListOf() @@ -252,8 +253,7 @@ private fun List.firstImmediatelyAvailable( return asyncFontsToLoad to fallbackTypeface } -internal class AsyncFontListLoader -constructor( +internal class AsyncFontListLoader( private val fontList: List, initialType: Any, private val typefaceRequest: TypefaceRequest, @@ -362,12 +362,12 @@ internal class AsyncTypefaceCache { // After loading, fonts are put into the resultCache to allow reading from a kotlin function // context, reducing async fonts overhead cache lookup overhead only while cached // @GuardedBy("cacheLock") - private val resultCache = SieveCache(16, 16) + private val resultCache = LruCache(16) // failures and preloads are permanent, so they are stored separately // @GuardedBy("cacheLock") private val permanentCache = mutableScatterMapOf() - private val cacheLock = createSynchronizedObject() + private val cacheLock = makeSynchronizedObject() fun put( font: Font, @@ -385,7 +385,7 @@ internal class AsyncTypefaceCache { permanentCache[key] = AsyncTypefaceResult(result) } else -> { - resultCache[key] = AsyncTypefaceResult(result) + resultCache.put(key, AsyncTypefaceResult(result)) } } } @@ -419,7 +419,7 @@ internal class AsyncTypefaceCache { permanentCache[key] = AsyncTypefaceResult(it) } else -> { - resultCache[key] = AsyncTypefaceResult(it) + resultCache.put(key, AsyncTypefaceResult(it)) } } } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt index 43dbe88bd0efa..7de23a6d32bb1 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt @@ -99,7 +99,7 @@ internal class FontMatcher { return result } - @Suppress("NOTHING_TO_INLINE") + @Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") // @VisibleForTesting internal inline fun List.filterByClosestWeight( fontWeight: FontWeight, diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt index 00e608413e37d..e9729565d664b 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt @@ -15,6 +15,10 @@ */ package androidx.compose.ui.text.font +private const val AllFlags = 0xffff +private const val WeightFlag = 0x1 +private const val StyleFlag = 0x2 + /** * Possible options for font synthesis. * @@ -39,13 +43,14 @@ value class FontSynthesis internal constructor(internal val value: Int) { override fun toString(): String { return when (this) { None -> "None" - All -> "All" Weight -> "Weight" Style -> "Style" + All -> "All" else -> "Invalid" } } + // NOTE: The values below are selected to be used as flags. See isWeightOn for instance. companion object { /** * Turns off font synthesis. Neither bold nor slanted faces are synthesized if they don't @@ -53,30 +58,30 @@ value class FontSynthesis internal constructor(internal val value: Int) { */ val None = FontSynthesis(0) - /** - * The system synthesizes both bold and slanted fonts if either of them are not available in - * the [FontFamily] - */ - val All = FontSynthesis(1) - /** * Only a bold font is synthesized, if it is not available in the [FontFamily]. Slanted * fonts will not be synthesized. */ - val Weight = FontSynthesis(2) + val Weight = FontSynthesis(WeightFlag) /** * Only an slanted font is synthesized, if it is not available in the [FontFamily]. Bold * fonts will not be synthesized. */ - val Style = FontSynthesis(3) + val Style = FontSynthesis(StyleFlag) + + /** + * The system synthesizes both bold and slanted fonts if either of them are not available in + * the [FontFamily] + */ + val All = FontSynthesis(AllFlags) } internal val isWeightOn: Boolean - get() = this == All || this == Weight + get() = value and WeightFlag != 0 internal val isStyleOn: Boolean - get() = this == All || this == Style + get() = value and StyleFlag != 0 } /** diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt index 53dcf8b58538e..0537796caca5a 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt @@ -17,6 +17,8 @@ package androidx.compose.ui.text.font import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.internal.requirePrecondition +import androidx.compose.ui.text.internal.requirePreconditionNotNull import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.util.fastAny @@ -130,7 +132,7 @@ object FontVariation { override fun toVariationValue(density: Density?): Float { // we don't care about pixel density as 12sp is the same "visual" size on all devices // instead we only care about font scaling, which changes visual size - requireNotNull(density) { "density must not be null" } + requirePreconditionNotNull(density) { "density must not be null" } return value.value * density.fontScale } @@ -206,7 +208,9 @@ object FontVariation { * @param value value for axis, not validated and directly passed to font */ fun Setting(name: String, value: Float): Setting { - require(name.length == 4) { "Name must be exactly four characters. Actual: '$name'" } + requirePrecondition(name.length == 4) { + "Name must be exactly four characters. Actual: '$name'" + } return SettingFloat(name, value) } @@ -226,7 +230,7 @@ object FontVariation { * @param value [0.0f, 1.0f] */ fun italic(value: Float): Setting { - require(value in 0.0f..1.0f) { "'ital' must be in 0.0f..1.0f. Actual: $value" } + requirePrecondition(value in 0.0f..1.0f) { "'ital' must be in 0.0f..1.0f. Actual: $value" } return SettingFloat("ital", value) } @@ -248,7 +252,7 @@ object FontVariation { * @param textSize font-size at the expected display, must be in sp */ fun opticalSizing(textSize: TextUnit): Setting { - require(textSize.isSp) { "'opsz' must be provided in sp units" } + requirePrecondition(textSize.isSp) { "'opsz' must be provided in sp units" } return SettingTextUnit("opsz", textSize) } @@ -263,7 +267,7 @@ object FontVariation { * @param value -90f to 90f, represents an angle */ fun slant(value: Float): Setting { - require(value in -90f..90f) { "'slnt' must be in -90f..90f. Actual: $value" } + requirePrecondition(value in -90f..90f) { "'slnt' must be in -90f..90f. Actual: $value" } return SettingFloat("slnt", value) } @@ -279,7 +283,7 @@ object FontVariation { * @param value > 0.0f represents the width */ fun width(value: Float): Setting { - require(value > 0.0f) { "'wdth' must be strictly > 0.0f. Actual: $value" } + requirePrecondition(value > 0.0f) { "'wdth' must be strictly > 0.0f. Actual: $value" } return SettingFloat("wdth", value) } @@ -304,7 +308,9 @@ object FontVariation { * @param value weight, in 1..1000 */ fun weight(value: Int): Setting { - require(value in 1..1000) { "'wght' value must be in [1, 1000]. Actual: $value" } + requirePrecondition(value in 1..1000) { + "'wght' value must be in [1, 1000]. Actual: $value" + } return SettingInt("wght", value) } @@ -324,7 +330,7 @@ object FontVariation { * @param value grade, in -1000..1000 */ fun grade(value: Int): Setting { - require(value in -1000..1000) { "'GRAD' must be in -1000..1000" } + requirePrecondition(value in -1000..1000) { "'GRAD' must be in -1000..1000" } return SettingInt("GRAD", value) } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt index 20795b3ceacf7..b3a8e29dddba1 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.text.font import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.ui.text.internal.requirePrecondition import androidx.compose.ui.util.lerp /** @@ -74,7 +75,7 @@ class FontWeight(val weight: Int) : Comparable { } init { - require(weight in 1..1000) { + requirePrecondition(weight in 1..1000) { "Font weight can be in range [1, 1000]. Current value: $weight" } } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt index 00346923936ed..018da9890c7c0 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.text.input import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.findFollowingBreak import androidx.compose.ui.text.findPrecedingBreak +import androidx.compose.ui.text.internal.requirePrecondition /** * [EditCommand] is a command representation for the platform IME API function calls. The commands @@ -246,7 +247,7 @@ class SetComposingTextCommand(val annotatedString: AnnotatedString, val newCurso class DeleteSurroundingTextCommand(val lengthBeforeCursor: Int, val lengthAfterCursor: Int) : EditCommand { init { - require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { + requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " + "$lengthBeforeCursor and $lengthAfterCursor respectively." } @@ -305,7 +306,7 @@ class DeleteSurroundingTextInCodePointsCommand( val lengthAfterCursor: Int ) : EditCommand { init { - require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { + requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " + "$lengthBeforeCursor and $lengthAfterCursor respectively." } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt index 4e1a9854205aa..26031c81514c2 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.text.input import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.InternalTextApi import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.internal.requirePrecondition /** * The editing buffer @@ -46,14 +47,18 @@ class EditingBuffer( /** The inclusive selection start offset */ internal var selectionStart = selection.min private set(value) { - require(value >= 0) { "Cannot set selectionStart to a negative value: $value" } + requirePrecondition(value >= 0) { + "Cannot set selectionStart to a negative value: $value" + } field = value } /** The exclusive selection end offset */ internal var selectionEnd = selection.max private set(value) { - require(value >= 0) { "Cannot set selectionEnd to a negative value: $value" } + requirePrecondition(value >= 0) { + "Cannot set selectionEnd to a negative value: $value" + } field = value } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt index 1275d48196277..8940352addee7 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.text.input import androidx.annotation.RestrictTo import androidx.compose.ui.text.InternalTextApi +import androidx.compose.ui.text.internal.requirePrecondition /** * Like [toCharArray] but copies the entire source string. Workaround for compiler error when giving @@ -238,10 +239,10 @@ class PartialGapBuffer(var text: String) { * @param text a text to replace */ fun replace(start: Int, end: Int, text: String) { - require(start <= end) { + requirePrecondition(start <= end) { "start index must be less than or equal to end index: $start > $end" } - require(start >= 0) { "start must be non-negative, but was $start" } + requirePrecondition(start >= 0) { "start must be non-negative, but was $start" } val buffer = buffer if (buffer == null) { // First time to create gap buffer diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt index 9f7a90004c31d..c4597f83f4e4e 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt @@ -16,17 +16,27 @@ package androidx.compose.ui.text.input +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + /** Adds [this] and [right], and if an overflow occurs returns result of [defaultValue]. */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) internal inline fun Int.addExactOrElse(right: Int, defaultValue: () -> Int): Int { + contract { callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) } val result = this + right - // HD 2-12 Overflow iff both arguments have the opposite sign of the result + // HD 2-12 Overflow if both arguments have the opposite sign of the result return if (this xor result and (right xor result) < 0) defaultValue() else result } /** Subtracts [right] from [this], and if an overflow occurs returns result of [defaultValue]. */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) internal inline fun Int.subtractExactOrElse(right: Int, defaultValue: () -> Int): Int { + contract { callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) } val result = this - right - // HD 2-12 Overflow iff the arguments have different signs and + // HD 2-12 Overflow if the arguments have different signs and // the sign of the result is different from the sign of x return if (this xor right and (this xor result) < 0) defaultValue() else result } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/internal/InlineClassHelper.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/internal/InlineClassHelper.kt new file mode 100644 index 0000000000000..036f341d1a338 --- /dev/null +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/internal/InlineClassHelper.kt @@ -0,0 +1,141 @@ +/* + * 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. + */ + +@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") + +package androidx.compose.ui.text.internal + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Throws an [IllegalStateException] with the specified [message]. This function is guaranteed to + * not be inlined, which reduces the amount of code generated at the call site. This code size + * reduction helps performance by not pre-caching instructions that will rarely/never be executed. + */ +internal fun throwIllegalStateException(message: String) { + throw IllegalStateException(message) +} + +/** + * Throws an [IllegalStateException] with the specified [message]. This function is guaranteed to + * not be inlined, which reduces the amount of code generated at the call site. This code size + * reduction helps performance by not pre-caching instructions that will rarely/never be executed. + * + * This function returns [Nothing] to tell the compiler it's a terminating branch in the code, + * making it suitable for use in a `when` statement or when doing a `null` check to force a smart + * cast to non-null (see [checkPreconditionNotNull]. + */ +internal fun throwIllegalStateExceptionForNullCheck(message: String): Nothing { + throw IllegalStateException(message) +} + +/** + * Throws an [IllegalArgumentException] with the specified [message]. This function is guaranteed to + * not be inlined, which reduces the amount of code generated at the call site. This code size + * reduction helps performance by not pre-caching instructions that will rarely/never be executed. + */ +internal fun throwIllegalArgumentException(message: String) { + throw IllegalArgumentException(message) +} + +/** + * Throws an [IllegalArgumentException] with the specified [message]. This function is guaranteed to + * not be inlined, which reduces the amount of code generated at the call site. This code size + * reduction helps performance by not pre-caching instructions that will rarely/never be executed. + * + * This function returns [Nothing] to tell the compiler it's a terminating branch in the code, + * making it suitable for use in a `when` statement or when doing a `null` check to force a smart + * cast to non-null (see [requirePreconditionNotNull]. + */ +internal fun throwIllegalArgumentExceptionForNullCheck(message: String): Nothing { + throw IllegalArgumentException(message) +} + +/** + * Like Kotlin's [check] but without the `toString()` call on the output of the lambda, and a + * non-inline throw. This implementation generates less code at the call site, which can help + * performance by not loading instructions that will rarely/never execute. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +internal inline fun checkPrecondition(value: Boolean, lazyMessage: () -> String) { + contract { + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + returns() implies value + } + if (!value) { + throwIllegalStateException(lazyMessage()) + } +} + +/** + * Like Kotlin's [checkNotNull] but without the `toString()` call on the output of the lambda, and a + * non-inline throw. This implementation generates less code at the call site, which can help + * performance by not loading instructions that will rarely/never execute. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +internal inline fun checkPreconditionNotNull(value: T?, lazyMessage: () -> String): T { + contract { + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + returns() implies (value != null) + } + + if (value == null) { + throwIllegalStateExceptionForNullCheck(lazyMessage()) + } + + return value +} + +/** + * Like Kotlin's [require] but without the `toString()` call on the output of the lambda, and a + * non-inline throw. This implementation generates less code at the call site, which can help + * performance by not loading instructions that will rarely/never execute. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) // same opt-in as using Kotlin's require() +internal inline fun requirePrecondition(value: Boolean, lazyMessage: () -> String) { + contract { + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + returns() implies value + } + if (!value) { + throwIllegalArgumentException(lazyMessage()) + } +} + +/** + * Like Kotlin's [requireNotNull] but without the `toString()` call on the output of the lambda, and + * a non-inline throw. This implementation generates less code at the call site, which can help + * performance by not loading instructions that will rarely/never execute. + */ +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +internal inline fun requirePreconditionNotNull(value: T?, lazyMessage: () -> String): T { + contract { + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + returns() implies (value != null) + } + + if (value == null) { + throwIllegalArgumentExceptionForNullCheck(lazyMessage()) + } + + return value +} diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt index 6cb71cfaa4c37..f48e5f46cc00d 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt @@ -35,7 +35,7 @@ class LocaleList(val localeList: List) : Collection { * An empty instance of [LocaleList]. Usually used to reference a lack of explicit [Locale] * configuration. */ - val Empty = LocaleList(emptyList()) + val Empty = LocaleList(listOf()) /** Returns Locale object which represents current locale */ val current: LocaleList diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/PlatformParagraph.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/PlatformParagraph.kt index c59035430cfe5..2bf9bcf20b2eb 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/PlatformParagraph.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/PlatformParagraph.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.ParagraphIntrinsics import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -40,7 +39,7 @@ import androidx.compose.ui.unit.Density internal expect fun ActualParagraph( text: String, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, maxLines: Int, ellipsis: Boolean, @@ -52,7 +51,7 @@ internal expect fun ActualParagraph( internal expect fun ActualParagraph( text: String, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, maxLines: Int, overflow: TextOverflow, @@ -73,7 +72,7 @@ internal expect fun ActualParagraph( internal expect fun ActualParagraphIntrinsics( text: String, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, density: Density, fontFamilyResolver: FontFamily.Resolver diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/Synchronization.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/Synchronization.kt index 84ccd5d9cdbbd..c43546447a45e 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/Synchronization.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/Synchronization.kt @@ -16,12 +16,14 @@ package androidx.compose.ui.text.platform -// TODO: Replace with another copy for expect/actual posix implementation +internal expect class SynchronizedObject -internal class SynchronizedObject : kotlinx.atomicfu.locks.SynchronizedObject() - -internal fun createSynchronizedObject() = SynchronizedObject() +/** + * Returns [ref] as a [SynchronizedObject] on platforms where [Any] is a valid [SynchronizedObject], + * or a new [SynchronizedObject] instance if [ref] is null or this is not supported on the current + * platform. + */ +internal expect inline fun makeSynchronizedObject(ref: Any? = null): SynchronizedObject @PublishedApi -internal inline fun synchronized(lock: SynchronizedObject, block: () -> R): R = - kotlinx.atomicfu.locks.synchronized(lock, block) +internal expect inline fun synchronized(lock: SynchronizedObject, block: () -> R): R diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt index 5e5af9d4fe849..414d0481ec1df 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.text.style import androidx.compose.ui.text.PlatformParagraphStyle +import androidx.compose.ui.text.internal.checkPrecondition import kotlin.jvm.JvmInline /** @@ -207,7 +208,7 @@ class LineHeightStyle(val alignment: Alignment, val trim: Trim, val mode: Mode) value class Alignment constructor(internal val topRatio: Float) { init { - check(topRatio in 0f..1f || topRatio == -1f) { + checkPrecondition(topRatio in 0f..1f || topRatio == -1f) { "topRatio should be in [0..1] range or -1" } } diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt index 9cd6708812b51..7b7878d484fa8 100644 --- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt +++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.lerp as lerpColor +import androidx.compose.ui.text.internal.requirePrecondition import androidx.compose.ui.text.lerpDiscrete import androidx.compose.ui.util.lerp import kotlin.jvm.JvmName @@ -89,7 +90,7 @@ internal interface TextForegroundStyle { private data class ColorStyle(val value: Color) : TextForegroundStyle { init { - require(value.isSpecified) { + requirePrecondition(value.isSpecified) { "ColorStyle value must be specified, use TextForegroundStyle.Unspecified instead." } } diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.skiko.kt index 6a786138ca306..5801466bdc9fb 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ActualParagraph.skiko.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.text.platform +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString.Range import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.ParagraphIntrinsics @@ -39,65 +40,71 @@ import kotlin.jvm.JvmName @Suppress("DEPRECATION") @Deprecated( "Font.ResourceLoader is deprecated, instead pass FontFamily.Resolver", - replaceWith = ReplaceWith("ActualParagraph(text, style, spanStyles, placeholders, " + - "maxLines, ellipsis, width, density, fontFamilyResolver)"), + replaceWith = + ReplaceWith( + "ActualParagraph(text, style, spanStyles, placeholders, " + + "maxLines, ellipsis, width, density, fontFamilyResolver)" + ), ) internal actual fun ActualParagraph( text: String, style: TextStyle, - spanStyles: List>, - placeholders: List>, + annotations: List>, + placeholders: List>, maxLines: Int, ellipsis: Boolean, width: Float, density: Density, @Suppress("DEPRECATION") resourceLoader: Font.ResourceLoader -): Paragraph = SkiaParagraph( - SkiaParagraphIntrinsics( - text, - style, - spanStyles, - placeholders, - density, - createFontFamilyResolver(resourceLoader) - ), - maxLines, - if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip, - Constraints(maxWidth = width.ceilToInt()) -) +): Paragraph = + SkiaParagraph( + SkiaParagraphIntrinsics( + text = text, + style = style, + placeholders = placeholders, + annotations = annotations, + fontFamilyResolver = createFontFamilyResolver(resourceLoader), + density = density + ), + maxLines, + if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip, + Constraints(maxWidth = width.ceilToInt()) + ) internal actual fun ActualParagraph( text: String, style: TextStyle, - spanStyles: List>, - placeholders: List>, + annotations: List>, + placeholders: List>, maxLines: Int, overflow: TextOverflow, constraints: Constraints, density: Density, fontFamilyResolver: FontFamily.Resolver -): Paragraph = SkiaParagraph( - SkiaParagraphIntrinsics( - text, - style, - spanStyles, - placeholders, - density, - fontFamilyResolver - ), - maxLines, - overflow, - constraints -) +): Paragraph = + SkiaParagraph( + SkiaParagraphIntrinsics( + text = text, + style = style, + placeholders = placeholders, + annotations = annotations, + fontFamilyResolver = fontFamilyResolver, + density = density + ), + maxLines, + overflow, + constraints + ) internal actual fun ActualParagraph( paragraphIntrinsics: ParagraphIntrinsics, maxLines: Int, overflow: TextOverflow, constraints: Constraints -): Paragraph = SkiaParagraph( - paragraphIntrinsics as SkiaParagraphIntrinsics, - maxLines, - overflow, - constraints -) +): Paragraph = + SkiaParagraph( + paragraphIntrinsics as SkiaParagraphIntrinsics, + maxLines, + overflow, + constraints + ) diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt index dbe2df098efc6..578d190234b6b 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawStyle -import androidx.compose.ui.text.AnnotatedString.Range +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.FontRasterizationSettings import androidx.compose.ui.text.Placeholder @@ -265,8 +265,8 @@ internal class ParagraphBuilder( var brushSize: Size = Size.Unspecified, var ellipsis: String = "", var maxLines: Int = Int.MAX_VALUE, - val spanStyles: List>, - val placeholders: List>, + var annotations: List>, + val placeholders: List>, val density: Density, val textDirection: ResolvedTextDirection, var drawStyle: DrawStyle? = null, @@ -310,7 +310,7 @@ internal class ParagraphBuilder( fun build(): SkParagraph { prepareDefaultStyle() ops = makeOps( - spanStyles, + annotations, placeholders ) @@ -422,13 +422,17 @@ internal class ParagraphBuilder( } private fun makeOps( - spans: List>, - placeholders: List> + annotations: List>, + placeholders: List> ): List { val cuts = mutableListOf() - for (span in spans) { - cuts.add(Cut.StyleAdd(span.start, span.item)) - cuts.add(Cut.StyleRemove(span.end, span.item)) + for (annotation in annotations) { + + // TODO https://youtrack.jetbrains.com/issue/CMP-7151/Support-ParagraphIntrinsics-with-annotations + annotation.item as SpanStyle + + cuts.add(Cut.StyleAdd(annotation.start, annotation.item)) + cuts.add(Cut.StyleRemove(annotation.end, annotation.item)) } for (placeholder in placeholders) { diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt index 25df889446124..7c7b1d3ab4f0d 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.skiko.kt @@ -61,7 +61,7 @@ internal class ParagraphLayouter( val text: String, textDirection: ResolvedTextDirection, style: TextStyle, - spanStyles: List>, + annotations: List>, placeholders: List>, density: Density, fontFamilyResolver: FontFamily.Resolver @@ -70,7 +70,7 @@ internal class ParagraphLayouter( fontFamilyResolver = fontFamilyResolver, text = text, textStyle = style, - spanStyles = spanStyles, + annotations = annotations, placeholders = placeholders, density = density, textDirection = textDirection @@ -85,7 +85,7 @@ internal class ParagraphLayouter( private fun invalidateParagraph(onlyForeground: Boolean = false) { // skia's updateForegroundPaint applies the same style to every span, // so if we have any, we need to rebuild the entire paragraph :'( - if (onlyForeground && builder.spanStyles.isEmpty()) { + if (onlyForeground && builder.annotations.isEmpty()) { updateForeground = true } else { paragraphCache = null @@ -151,7 +151,9 @@ internal class ParagraphLayouter( // but we have to invalidate it because it's backed into skia's paragraph. // Since it affects only [ShaderBrush] we can keep the cache if it's not used. if (builder.textStyle.brush is ShaderBrush || - builder.spanStyles.any { it.item.brush is ShaderBrush }) { + builder.annotations.any { + it.item is SpanStyle && // TODO(ivan): Verify that we need only [SpanStyle] here + it.item.brush is ShaderBrush }) { invalidateParagraph(onlyForeground = true) } } diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaParagraphIntrinsics.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaParagraphIntrinsics.skiko.kt index d23dc1cf1eb74..9532a8ebc682d 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaParagraphIntrinsics.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/SkiaParagraphIntrinsics.skiko.kt @@ -29,15 +29,15 @@ import kotlin.math.ceil internal actual fun ActualParagraphIntrinsics( text: String, style: TextStyle, - spanStyles: List>, - placeholders: List>, + annotations: List>, + placeholders: List>, density: Density, fontFamilyResolver: FontFamily.Resolver ): ParagraphIntrinsics = SkiaParagraphIntrinsics( text, style, - spanStyles, + annotations, placeholders, density, fontFamilyResolver @@ -46,7 +46,7 @@ internal actual fun ActualParagraphIntrinsics( internal class SkiaParagraphIntrinsics( val text: String, private val style: TextStyle, - private val spanStyles: List>, + private val annotations: List>, private val placeholders: List>, private val density: Density, private val fontFamilyResolver: FontFamily.Resolver @@ -65,7 +65,7 @@ internal class SkiaParagraphIntrinsics( text = text, textDirection = textDirection, style = style, - spanStyles = spanStyles, + annotations = annotations, placeholders = placeholders, density = density, fontFamilyResolver = fontFamilyResolver diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/Synchronization.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/Synchronization.skiko.kt new file mode 100644 index 0000000000000..ba491010311c2 --- /dev/null +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/Synchronization.skiko.kt @@ -0,0 +1,35 @@ +/* + * 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. + */ +@file:JvmName("SynchronizationKt") + +package androidx.compose.ui.text.platform + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.jvm.JvmName + +internal actual class SynchronizedObject : kotlinx.atomicfu.locks.SynchronizedObject() + +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + +@PublishedApi +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + return kotlinx.atomicfu.locks.synchronized(lock, block) +} diff --git a/compose/ui/ui-tooling-preview/api/current.txt b/compose/ui/ui-tooling-preview/api/current.txt index e7b548bdb8ea9..b431232da8486 100644 --- a/compose/ui/ui-tooling-preview/api/current.txt +++ b/compose/ui/ui-tooling-preview/api/current.txt @@ -6,6 +6,43 @@ package androidx.compose.ui.tooling.preview { method @Deprecated public String getWEAR_OS_RECT(); method @Deprecated public String getWEAR_OS_SMALL_ROUND(); method @Deprecated public String getWEAR_OS_SQUARE(); + property public static final String AUTOMOTIVE_1024p; + property public static final String DEFAULT; + property public static final String DESKTOP; + property public static final String FOLDABLE; + property public static final String NEXUS_10; + property public static final String NEXUS_5; + property public static final String NEXUS_5X; + property public static final String NEXUS_6; + property public static final String NEXUS_6P; + property public static final String NEXUS_7; + property public static final String NEXUS_7_2013; + property public static final String NEXUS_9; + property public static final String PHONE; + property public static final String PIXEL; + property public static final String PIXEL_2; + property public static final String PIXEL_2_XL; + property public static final String PIXEL_3; + property public static final String PIXEL_3A; + property public static final String PIXEL_3A_XL; + property public static final String PIXEL_3_XL; + property public static final String PIXEL_4; + property public static final String PIXEL_4A; + property public static final String PIXEL_4_XL; + property public static final String PIXEL_5; + property public static final String PIXEL_6; + property public static final String PIXEL_6A; + property public static final String PIXEL_6_PRO; + property public static final String PIXEL_7; + property public static final String PIXEL_7A; + property public static final String PIXEL_7_PRO; + property public static final String PIXEL_C; + property public static final String PIXEL_FOLD; + property public static final String PIXEL_TABLET; + property public static final String PIXEL_XL; + property public static final String TABLET; + property public static final String TV_1080p; + property public static final String TV_720p; property @Deprecated public String WEAR_OS_LARGE_ROUND; property @Deprecated public String WEAR_OS_RECT; property @Deprecated public String WEAR_OS_SMALL_ROUND; @@ -68,10 +105,10 @@ package androidx.compose.ui.tooling.preview { method public abstract int uiMode() default 0; method public abstract int wallpaper() default androidx.compose.ui.tooling.preview.Wallpapers.NONE; method public abstract int widthDp() default -1; - property public abstract int apiLevel; + property @IntRange(from=1L) public abstract int apiLevel; property public abstract long backgroundColor; property public abstract String device; - property public abstract float fontScale; + property @FloatRange(from=0.01) public abstract float fontScale; property public abstract String group; property public abstract int heightDp; property public abstract String locale; @@ -85,6 +122,19 @@ package androidx.compose.ui.tooling.preview { @kotlin.annotation.MustBeDocumented @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public static @interface Preview.Container { method public abstract androidx.compose.ui.tooling.preview.Preview[] value(); + property @IntRange(from=1L) public abstract int apiLevel; + property public abstract long backgroundColor; + property public abstract String device; + property @FloatRange(from=0.01) public abstract float fontScale; + property public abstract String group; + property public abstract int heightDp; + property public abstract String locale; + property public abstract String name; + property public abstract boolean showBackground; + property public abstract boolean showSystemUi; + property public abstract int uiMode; + property public abstract int wallpaper; + property public abstract int widthDp; } @androidx.compose.ui.tooling.preview.Preview(name="Red", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE) @androidx.compose.ui.tooling.preview.Preview(name="Blue", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.BLUE_DOMINATED_EXAMPLE) @androidx.compose.ui.tooling.preview.Preview(name="Green", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE) @androidx.compose.ui.tooling.preview.Preview(name="Yellow", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.YELLOW_DOMINATED_EXAMPLE) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface PreviewDynamicColors { @@ -114,6 +164,11 @@ package androidx.compose.ui.tooling.preview { } public final class Wallpapers { + property public static final int BLUE_DOMINATED_EXAMPLE; + property public static final int GREEN_DOMINATED_EXAMPLE; + property public static final int NONE; + property public static final int RED_DOMINATED_EXAMPLE; + property public static final int YELLOW_DOMINATED_EXAMPLE; field public static final int BLUE_DOMINATED_EXAMPLE = 2; // 0x2 field public static final int GREEN_DOMINATED_EXAMPLE = 1; // 0x1 field public static final androidx.compose.ui.tooling.preview.Wallpapers INSTANCE; diff --git a/compose/ui/ui-tooling-preview/api/restricted_current.txt b/compose/ui/ui-tooling-preview/api/restricted_current.txt index e7b548bdb8ea9..b431232da8486 100644 --- a/compose/ui/ui-tooling-preview/api/restricted_current.txt +++ b/compose/ui/ui-tooling-preview/api/restricted_current.txt @@ -6,6 +6,43 @@ package androidx.compose.ui.tooling.preview { method @Deprecated public String getWEAR_OS_RECT(); method @Deprecated public String getWEAR_OS_SMALL_ROUND(); method @Deprecated public String getWEAR_OS_SQUARE(); + property public static final String AUTOMOTIVE_1024p; + property public static final String DEFAULT; + property public static final String DESKTOP; + property public static final String FOLDABLE; + property public static final String NEXUS_10; + property public static final String NEXUS_5; + property public static final String NEXUS_5X; + property public static final String NEXUS_6; + property public static final String NEXUS_6P; + property public static final String NEXUS_7; + property public static final String NEXUS_7_2013; + property public static final String NEXUS_9; + property public static final String PHONE; + property public static final String PIXEL; + property public static final String PIXEL_2; + property public static final String PIXEL_2_XL; + property public static final String PIXEL_3; + property public static final String PIXEL_3A; + property public static final String PIXEL_3A_XL; + property public static final String PIXEL_3_XL; + property public static final String PIXEL_4; + property public static final String PIXEL_4A; + property public static final String PIXEL_4_XL; + property public static final String PIXEL_5; + property public static final String PIXEL_6; + property public static final String PIXEL_6A; + property public static final String PIXEL_6_PRO; + property public static final String PIXEL_7; + property public static final String PIXEL_7A; + property public static final String PIXEL_7_PRO; + property public static final String PIXEL_C; + property public static final String PIXEL_FOLD; + property public static final String PIXEL_TABLET; + property public static final String PIXEL_XL; + property public static final String TABLET; + property public static final String TV_1080p; + property public static final String TV_720p; property @Deprecated public String WEAR_OS_LARGE_ROUND; property @Deprecated public String WEAR_OS_RECT; property @Deprecated public String WEAR_OS_SMALL_ROUND; @@ -68,10 +105,10 @@ package androidx.compose.ui.tooling.preview { method public abstract int uiMode() default 0; method public abstract int wallpaper() default androidx.compose.ui.tooling.preview.Wallpapers.NONE; method public abstract int widthDp() default -1; - property public abstract int apiLevel; + property @IntRange(from=1L) public abstract int apiLevel; property public abstract long backgroundColor; property public abstract String device; - property public abstract float fontScale; + property @FloatRange(from=0.01) public abstract float fontScale; property public abstract String group; property public abstract int heightDp; property public abstract String locale; @@ -85,6 +122,19 @@ package androidx.compose.ui.tooling.preview { @kotlin.annotation.MustBeDocumented @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public static @interface Preview.Container { method public abstract androidx.compose.ui.tooling.preview.Preview[] value(); + property @IntRange(from=1L) public abstract int apiLevel; + property public abstract long backgroundColor; + property public abstract String device; + property @FloatRange(from=0.01) public abstract float fontScale; + property public abstract String group; + property public abstract int heightDp; + property public abstract String locale; + property public abstract String name; + property public abstract boolean showBackground; + property public abstract boolean showSystemUi; + property public abstract int uiMode; + property public abstract int wallpaper; + property public abstract int widthDp; } @androidx.compose.ui.tooling.preview.Preview(name="Red", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE) @androidx.compose.ui.tooling.preview.Preview(name="Blue", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.BLUE_DOMINATED_EXAMPLE) @androidx.compose.ui.tooling.preview.Preview(name="Green", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE) @androidx.compose.ui.tooling.preview.Preview(name="Yellow", wallpaper=androidx.compose.ui.tooling.preview.Wallpapers.YELLOW_DOMINATED_EXAMPLE) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface PreviewDynamicColors { @@ -114,6 +164,11 @@ package androidx.compose.ui.tooling.preview { } public final class Wallpapers { + property public static final int BLUE_DOMINATED_EXAMPLE; + property public static final int GREEN_DOMINATED_EXAMPLE; + property public static final int NONE; + property public static final int RED_DOMINATED_EXAMPLE; + property public static final int YELLOW_DOMINATED_EXAMPLE; field public static final int BLUE_DOMINATED_EXAMPLE = 2; // 0x2 field public static final int GREEN_DOMINATED_EXAMPLE = 1; // 0x1 field public static final androidx.compose.ui.tooling.preview.Wallpapers INSTANCE; diff --git a/compose/ui/ui-unit/api/current.ignore b/compose/ui/ui-unit/api/current.ignore index 0e3bda57fb6cf..f76401b0979c6 100644 --- a/compose/ui/ui-unit/api/current.ignore +++ b/compose/ui/ui-unit/api/current.ignore @@ -1,3 +1,47 @@ // Baseline format: 1.0 -RemovedClass: androidx.compose.ui.unit.FontScalingKt: - Removed class androidx.compose.ui.unit.FontScalingKt +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasBoundedHeight(): + Removed method androidx.compose.ui.unit.Constraints.getHasBoundedHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasBoundedWidth(): + Removed method androidx.compose.ui.unit.Constraints.getHasBoundedWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasFixedHeight(): + Removed method androidx.compose.ui.unit.Constraints.getHasFixedHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasFixedWidth(): + Removed method androidx.compose.ui.unit.Constraints.getHasFixedWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMaxHeight(): + Removed method androidx.compose.ui.unit.Constraints.getMaxHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMaxWidth(): + Removed method androidx.compose.ui.unit.Constraints.getMaxWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMinHeight(): + Removed method androidx.compose.ui.unit.Constraints.getMinHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMinWidth(): + Removed method androidx.compose.ui.unit.Constraints.getMinWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#isZero(): + Removed method androidx.compose.ui.unit.Constraints.isZero() +RemovedMethod: androidx.compose.ui.unit.DpOffset#getX(): + Removed method androidx.compose.ui.unit.DpOffset.getX() +RemovedMethod: androidx.compose.ui.unit.DpOffset#getY(): + Removed method androidx.compose.ui.unit.DpOffset.getY() +RemovedMethod: androidx.compose.ui.unit.DpSize#getHeight(): + Removed method androidx.compose.ui.unit.DpSize.getHeight() +RemovedMethod: androidx.compose.ui.unit.DpSize#getWidth(): + Removed method androidx.compose.ui.unit.DpSize.getWidth() +RemovedMethod: androidx.compose.ui.unit.IntOffset#getX(): + Removed method androidx.compose.ui.unit.IntOffset.getX() +RemovedMethod: androidx.compose.ui.unit.IntOffset#getY(): + Removed method androidx.compose.ui.unit.IntOffset.getY() +RemovedMethod: androidx.compose.ui.unit.IntSize#getHeight(): + Removed method androidx.compose.ui.unit.IntSize.getHeight() +RemovedMethod: androidx.compose.ui.unit.IntSize#getWidth(): + Removed method androidx.compose.ui.unit.IntSize.getWidth() +RemovedMethod: androidx.compose.ui.unit.TextUnit#getType(): + Removed method androidx.compose.ui.unit.TextUnit.getType() +RemovedMethod: androidx.compose.ui.unit.TextUnit#getValue(): + Removed method androidx.compose.ui.unit.TextUnit.getValue() +RemovedMethod: androidx.compose.ui.unit.TextUnit#isEm(): + Removed method androidx.compose.ui.unit.TextUnit.isEm() +RemovedMethod: androidx.compose.ui.unit.TextUnit#isSp(): + Removed method androidx.compose.ui.unit.TextUnit.isSp() +RemovedMethod: androidx.compose.ui.unit.Velocity#getX(): + Removed method androidx.compose.ui.unit.Velocity.getX() +RemovedMethod: androidx.compose.ui.unit.Velocity#getY(): + Removed method androidx.compose.ui.unit.Velocity.getY() diff --git a/compose/ui/ui-unit/api/current.txt b/compose/ui/ui-unit/api/current.txt index f22b33709d003..8a46fcd8e94e2 100644 --- a/compose/ui/ui-unit/api/current.txt +++ b/compose/ui/ui-unit/api/current.txt @@ -9,15 +9,6 @@ package androidx.compose.ui.unit { ctor public Constraints(@kotlin.PublishedApi long value); method public long copy(optional int minWidth, optional int maxWidth, optional int minHeight, optional int maxHeight); method public inline long copyMaxDimensions(); - method public boolean getHasBoundedHeight(); - method public boolean getHasBoundedWidth(); - method public boolean getHasFixedHeight(); - method public boolean getHasFixedWidth(); - method public int getMaxHeight(); - method public int getMaxWidth(); - method public int getMinHeight(); - method public int getMinWidth(); - method public boolean isZero(); property public final boolean hasBoundedHeight; property public final boolean hasBoundedWidth; property @androidx.compose.runtime.Stable public final boolean hasFixedHeight; @@ -38,6 +29,7 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public long fixedHeight(int height); method @androidx.compose.runtime.Stable public long fixedWidth(int width); method @Deprecated @androidx.compose.runtime.Stable public long restrictConstraints(int minWidth, int maxWidth, int minHeight, int maxHeight, optional boolean prioritizeWidth); + property public static final int Infinity; } public final class ConstraintsKt { @@ -62,7 +54,7 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public default androidx.compose.ui.geometry.Rect toRect(androidx.compose.ui.unit.DpRect); method @androidx.compose.runtime.Stable public default long toSize(long); method @androidx.compose.runtime.Stable public default long toSp(int); - property public abstract float density; + property @androidx.compose.runtime.Stable public abstract float density; } public final class DensityKt { @@ -89,9 +81,9 @@ package androidx.compose.ui.unit { method public float getHairline(); method public float getInfinity(); method public float getUnspecified(); - property public final float Hairline; - property public final float Infinity; - property public final float Unspecified; + property @androidx.compose.runtime.Stable public final float Hairline; + property @androidx.compose.runtime.Stable public final float Infinity; + property @androidx.compose.runtime.Stable public final float Unspecified; } public final class DpKt { @@ -133,8 +125,6 @@ package androidx.compose.ui.unit { ctor public DpOffset(long packedValue); method public long copy(optional float x, optional float y); method public long getPackedValue(); - method public float getX(); - method public float getY(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); property public final long packedValue; @@ -162,10 +152,10 @@ package androidx.compose.ui.unit { method public float getLeft(); method public float getRight(); method public float getTop(); - property public final float bottom; - property public final float left; - property public final float right; - property public final float top; + property @androidx.compose.runtime.Stable public final float bottom; + property @androidx.compose.runtime.Stable public final float left; + property @androidx.compose.runtime.Stable public final float right; + property @androidx.compose.runtime.Stable public final float top; field public static final androidx.compose.ui.unit.DpRect.Companion Companion; } @@ -178,8 +168,6 @@ package androidx.compose.ui.unit { method public long copy(optional float width, optional float height); method @androidx.compose.runtime.Stable public operator long div(float other); method @androidx.compose.runtime.Stable public operator long div(int other); - method public float getHeight(); - method public float getWidth(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); method @androidx.compose.runtime.Stable public operator long times(float other); @@ -203,7 +191,7 @@ package androidx.compose.ui.unit { method public float getFontScale(); method @androidx.compose.runtime.Stable public default float toDp(long); method @androidx.compose.runtime.Stable public default long toSp(float); - property public abstract float fontScale; + property @androidx.compose.runtime.Stable public abstract float fontScale; } @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class IntOffset { @@ -213,8 +201,6 @@ package androidx.compose.ui.unit { method public long copy(optional int x, optional int y); method @androidx.compose.runtime.Stable public operator long div(float operand); method public long getPackedValue(); - method public int getX(); - method public int getY(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); method @androidx.compose.runtime.Stable public operator long rem(int operand); @@ -277,7 +263,7 @@ package androidx.compose.ui.unit { method public boolean overlaps(androidx.compose.ui.unit.IntRect other); method @androidx.compose.runtime.Stable public androidx.compose.ui.unit.IntRect translate(int translateX, int translateY); method @androidx.compose.runtime.Stable public androidx.compose.ui.unit.IntRect translate(long offset); - property public final int bottom; + property @androidx.compose.runtime.Stable public final int bottom; property public final long bottomCenter; property public final long bottomLeft; property public final long bottomRight; @@ -286,12 +272,12 @@ package androidx.compose.ui.unit { property public final long centerRight; property @androidx.compose.runtime.Stable public final int height; property @androidx.compose.runtime.Stable public final boolean isEmpty; - property public final int left; + property @androidx.compose.runtime.Stable public final int left; property public final int maxDimension; property public final int minDimension; - property public final int right; + property @androidx.compose.runtime.Stable public final int right; property @androidx.compose.runtime.Stable public final long size; - property public final int top; + property @androidx.compose.runtime.Stable public final int top; property public final long topCenter; property public final long topLeft; property public final long topRight; @@ -301,7 +287,7 @@ package androidx.compose.ui.unit { public static final class IntRect.Companion { method public androidx.compose.ui.unit.IntRect getZero(); - property public final androidx.compose.ui.unit.IntRect Zero; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.unit.IntRect Zero; } public final class IntRectKt { @@ -317,9 +303,7 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public inline operator int component1(); method @androidx.compose.runtime.Stable public inline operator int component2(); method @androidx.compose.runtime.Stable public operator long div(int other); - method public inline int getHeight(); method public long getPackedValue(); - method public inline int getWidth(); method @androidx.compose.runtime.Stable public operator long times(int other); property @androidx.compose.runtime.Stable public final inline int height; property public final long packedValue; @@ -352,10 +336,6 @@ package androidx.compose.ui.unit { method public inline operator long div(double other); method public inline operator long div(float other); method public inline operator long div(int other); - method public long getType(); - method public float getValue(); - method public boolean isEm(); - method public boolean isSp(); method public inline operator long times(double other); method public inline operator long times(float other); method public inline operator long times(int other); @@ -369,7 +349,7 @@ package androidx.compose.ui.unit { public static final class TextUnit.Companion { method public long getUnspecified(); - property public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Unspecified; } public final class TextUnitKt { @@ -408,8 +388,6 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public inline operator float component2(); method public long copy(optional float x, optional float y); method @androidx.compose.runtime.Stable public operator long div(float operand); - method public float getX(); - method public float getY(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); method @androidx.compose.runtime.Stable public operator long rem(float operand); @@ -422,7 +400,7 @@ package androidx.compose.ui.unit { public static final class Velocity.Companion { method public long getZero(); - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Zero; } public final class VelocityKt { diff --git a/compose/ui/ui-unit/api/restricted_current.ignore b/compose/ui/ui-unit/api/restricted_current.ignore index 0e3bda57fb6cf..f76401b0979c6 100644 --- a/compose/ui/ui-unit/api/restricted_current.ignore +++ b/compose/ui/ui-unit/api/restricted_current.ignore @@ -1,3 +1,47 @@ // Baseline format: 1.0 -RemovedClass: androidx.compose.ui.unit.FontScalingKt: - Removed class androidx.compose.ui.unit.FontScalingKt +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasBoundedHeight(): + Removed method androidx.compose.ui.unit.Constraints.getHasBoundedHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasBoundedWidth(): + Removed method androidx.compose.ui.unit.Constraints.getHasBoundedWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasFixedHeight(): + Removed method androidx.compose.ui.unit.Constraints.getHasFixedHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getHasFixedWidth(): + Removed method androidx.compose.ui.unit.Constraints.getHasFixedWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMaxHeight(): + Removed method androidx.compose.ui.unit.Constraints.getMaxHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMaxWidth(): + Removed method androidx.compose.ui.unit.Constraints.getMaxWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMinHeight(): + Removed method androidx.compose.ui.unit.Constraints.getMinHeight() +RemovedMethod: androidx.compose.ui.unit.Constraints#getMinWidth(): + Removed method androidx.compose.ui.unit.Constraints.getMinWidth() +RemovedMethod: androidx.compose.ui.unit.Constraints#isZero(): + Removed method androidx.compose.ui.unit.Constraints.isZero() +RemovedMethod: androidx.compose.ui.unit.DpOffset#getX(): + Removed method androidx.compose.ui.unit.DpOffset.getX() +RemovedMethod: androidx.compose.ui.unit.DpOffset#getY(): + Removed method androidx.compose.ui.unit.DpOffset.getY() +RemovedMethod: androidx.compose.ui.unit.DpSize#getHeight(): + Removed method androidx.compose.ui.unit.DpSize.getHeight() +RemovedMethod: androidx.compose.ui.unit.DpSize#getWidth(): + Removed method androidx.compose.ui.unit.DpSize.getWidth() +RemovedMethod: androidx.compose.ui.unit.IntOffset#getX(): + Removed method androidx.compose.ui.unit.IntOffset.getX() +RemovedMethod: androidx.compose.ui.unit.IntOffset#getY(): + Removed method androidx.compose.ui.unit.IntOffset.getY() +RemovedMethod: androidx.compose.ui.unit.IntSize#getHeight(): + Removed method androidx.compose.ui.unit.IntSize.getHeight() +RemovedMethod: androidx.compose.ui.unit.IntSize#getWidth(): + Removed method androidx.compose.ui.unit.IntSize.getWidth() +RemovedMethod: androidx.compose.ui.unit.TextUnit#getType(): + Removed method androidx.compose.ui.unit.TextUnit.getType() +RemovedMethod: androidx.compose.ui.unit.TextUnit#getValue(): + Removed method androidx.compose.ui.unit.TextUnit.getValue() +RemovedMethod: androidx.compose.ui.unit.TextUnit#isEm(): + Removed method androidx.compose.ui.unit.TextUnit.isEm() +RemovedMethod: androidx.compose.ui.unit.TextUnit#isSp(): + Removed method androidx.compose.ui.unit.TextUnit.isSp() +RemovedMethod: androidx.compose.ui.unit.Velocity#getX(): + Removed method androidx.compose.ui.unit.Velocity.getX() +RemovedMethod: androidx.compose.ui.unit.Velocity#getY(): + Removed method androidx.compose.ui.unit.Velocity.getY() diff --git a/compose/ui/ui-unit/api/restricted_current.txt b/compose/ui/ui-unit/api/restricted_current.txt index 4512aacb4ab86..826a69744101f 100644 --- a/compose/ui/ui-unit/api/restricted_current.txt +++ b/compose/ui/ui-unit/api/restricted_current.txt @@ -9,15 +9,6 @@ package androidx.compose.ui.unit { ctor public Constraints(@kotlin.PublishedApi long value); method public long copy(optional int minWidth, optional int maxWidth, optional int minHeight, optional int maxHeight); method public inline long copyMaxDimensions(); - method public boolean getHasBoundedHeight(); - method public boolean getHasBoundedWidth(); - method public boolean getHasFixedHeight(); - method public boolean getHasFixedWidth(); - method public int getMaxHeight(); - method public int getMaxWidth(); - method public int getMinHeight(); - method public int getMinWidth(); - method public boolean isZero(); property public final boolean hasBoundedHeight; property public final boolean hasBoundedWidth; property @androidx.compose.runtime.Stable public final boolean hasFixedHeight; @@ -27,6 +18,7 @@ package androidx.compose.ui.unit { property public final int maxWidth; property public final int minHeight; property public final int minWidth; + property @kotlin.PublishedApi internal final long value; field public static final androidx.compose.ui.unit.Constraints.Companion Companion; field public static final int Infinity = 2147483647; // 0x7fffffff } @@ -38,6 +30,7 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public long fixedHeight(int height); method @androidx.compose.runtime.Stable public long fixedWidth(int width); method @Deprecated @androidx.compose.runtime.Stable public long restrictConstraints(int minWidth, int maxWidth, int minHeight, int maxHeight, optional boolean prioritizeWidth); + property public static final int Infinity; } public final class ConstraintsKt { @@ -48,6 +41,7 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public static int constrainWidth(long, int width); method @androidx.compose.runtime.Stable public static boolean isSatisfiedBy(long, long size); method @androidx.compose.runtime.Stable public static long offset(long, optional int horizontal, optional int vertical); + property @kotlin.PublishedApi internal static final long MaxDimensionsAndFocusMask; field @kotlin.PublishedApi internal static final long MaxDimensionsAndFocusMask = -8589934589L; // 0xfffffffe00000003L } @@ -63,7 +57,7 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public default androidx.compose.ui.geometry.Rect toRect(androidx.compose.ui.unit.DpRect); method @androidx.compose.runtime.Stable public default long toSize(long); method @androidx.compose.runtime.Stable public default long toSp(int); - property public abstract float density; + property @androidx.compose.runtime.Stable public abstract float density; } public final class DensityKt { @@ -90,9 +84,9 @@ package androidx.compose.ui.unit { method public float getHairline(); method public float getInfinity(); method public float getUnspecified(); - property public final float Hairline; - property public final float Infinity; - property public final float Unspecified; + property @androidx.compose.runtime.Stable public final float Hairline; + property @androidx.compose.runtime.Stable public final float Infinity; + property @androidx.compose.runtime.Stable public final float Unspecified; } public final class DpKt { @@ -134,8 +128,6 @@ package androidx.compose.ui.unit { ctor public DpOffset(long packedValue); method public long copy(optional float x, optional float y); method public long getPackedValue(); - method public float getX(); - method public float getY(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); property public final long packedValue; @@ -163,10 +155,10 @@ package androidx.compose.ui.unit { method public float getLeft(); method public float getRight(); method public float getTop(); - property public final float bottom; - property public final float left; - property public final float right; - property public final float top; + property @androidx.compose.runtime.Stable public final float bottom; + property @androidx.compose.runtime.Stable public final float left; + property @androidx.compose.runtime.Stable public final float right; + property @androidx.compose.runtime.Stable public final float top; field public static final androidx.compose.ui.unit.DpRect.Companion Companion; } @@ -179,13 +171,12 @@ package androidx.compose.ui.unit { method public long copy(optional float width, optional float height); method @androidx.compose.runtime.Stable public operator long div(float other); method @androidx.compose.runtime.Stable public operator long div(int other); - method public float getHeight(); - method public float getWidth(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); method @androidx.compose.runtime.Stable public operator long times(float other); method @androidx.compose.runtime.Stable public operator long times(int other); property @androidx.compose.runtime.Stable public final float height; + property @kotlin.PublishedApi internal final long packedValue; property @androidx.compose.runtime.Stable public final float width; field public static final androidx.compose.ui.unit.DpSize.Companion Companion; } @@ -204,7 +195,7 @@ package androidx.compose.ui.unit { method public float getFontScale(); method @androidx.compose.runtime.Stable public default float toDp(long); method @androidx.compose.runtime.Stable public default long toSp(float); - property public abstract float fontScale; + property @androidx.compose.runtime.Stable public abstract float fontScale; } @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class IntOffset { @@ -214,8 +205,6 @@ package androidx.compose.ui.unit { method public long copy(optional int x, optional int y); method @androidx.compose.runtime.Stable public operator long div(float operand); method public long getPackedValue(); - method public int getX(); - method public int getY(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); method @androidx.compose.runtime.Stable public operator long rem(int operand); @@ -278,7 +267,7 @@ package androidx.compose.ui.unit { method public boolean overlaps(androidx.compose.ui.unit.IntRect other); method @androidx.compose.runtime.Stable public androidx.compose.ui.unit.IntRect translate(int translateX, int translateY); method @androidx.compose.runtime.Stable public androidx.compose.ui.unit.IntRect translate(long offset); - property public final int bottom; + property @androidx.compose.runtime.Stable public final int bottom; property public final long bottomCenter; property public final long bottomLeft; property public final long bottomRight; @@ -287,12 +276,12 @@ package androidx.compose.ui.unit { property public final long centerRight; property @androidx.compose.runtime.Stable public final int height; property @androidx.compose.runtime.Stable public final boolean isEmpty; - property public final int left; + property @androidx.compose.runtime.Stable public final int left; property public final int maxDimension; property public final int minDimension; - property public final int right; + property @androidx.compose.runtime.Stable public final int right; property @androidx.compose.runtime.Stable public final long size; - property public final int top; + property @androidx.compose.runtime.Stable public final int top; property public final long topCenter; property public final long topLeft; property public final long topRight; @@ -302,7 +291,7 @@ package androidx.compose.ui.unit { public static final class IntRect.Companion { method public androidx.compose.ui.unit.IntRect getZero(); - property public final androidx.compose.ui.unit.IntRect Zero; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.unit.IntRect Zero; } public final class IntRectKt { @@ -319,9 +308,7 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public inline operator int component1(); method @androidx.compose.runtime.Stable public inline operator int component2(); method @androidx.compose.runtime.Stable public operator long div(int other); - method public inline int getHeight(); method public long getPackedValue(); - method public inline int getWidth(); method @androidx.compose.runtime.Stable public operator long times(int other); property @androidx.compose.runtime.Stable public final inline int height; property public final long packedValue; @@ -354,10 +341,6 @@ package androidx.compose.ui.unit { method public inline operator long div(double other); method public inline operator long div(float other); method public inline operator long div(int other); - method public long getType(); - method public float getValue(); - method public boolean isEm(); - method public boolean isSp(); method public inline operator long times(double other); method public inline operator long times(float other); method public inline operator long times(int other); @@ -372,7 +355,7 @@ package androidx.compose.ui.unit { public static final class TextUnit.Companion { method public long getUnspecified(); - property public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Unspecified; } public final class TextUnitKt { @@ -415,8 +398,6 @@ package androidx.compose.ui.unit { method @androidx.compose.runtime.Stable public inline operator float component2(); method public long copy(optional float x, optional float y); method @androidx.compose.runtime.Stable public operator long div(float operand); - method public float getX(); - method public float getY(); method @androidx.compose.runtime.Stable public operator long minus(long other); method @androidx.compose.runtime.Stable public operator long plus(long other); method @androidx.compose.runtime.Stable public operator long rem(float operand); @@ -429,7 +410,7 @@ package androidx.compose.ui.unit { public static final class Velocity.Companion { method public long getZero(); - property public final long Zero; + property @androidx.compose.runtime.Stable public final long Zero; } public final class VelocityKt { diff --git a/compose/ui/ui-unit/proguard-rules.pro b/compose/ui/ui-unit/proguard-rules.pro new file mode 100644 index 0000000000000..67d118b98fa5e --- /dev/null +++ b/compose/ui/ui-unit/proguard-rules.pro @@ -0,0 +1,24 @@ +# Copyright (C) 2020 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. + +# Keep all the functions created to throw an exception. We don't want these functions to be +# inlined in any way, which R8 will do by default. The whole point of these functions is to +# reduce the amount of code generated at the call site. +-keep,allowshrinking,allowobfuscation class androidx.compose.**.* { + static void throw*Exception(...); + static void throw*ExceptionForNullCheck(...); + # For methods returning Nothing + static java.lang.Void throw*Exception(...); + static java.lang.Void throw*ExceptionForNullCheck(...); +} diff --git a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt index e1b43156a0323..79ba2da854404 100644 --- a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt +++ b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt @@ -20,7 +20,6 @@ package androidx.compose.ui.unit import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.compose.ui.unit.Constraints.Companion.Infinity import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceIn import kotlin.jvm.JvmInline @@ -351,40 +350,17 @@ value class Constraints(@PublishedApi internal val value: Long) { private const val Infinity = Int.MAX_VALUE /** - * The bit distribution when the focus of the bits should be on the width, but only a minimal - * difference in focus. - * - * 16 bits assigned to width, 15 bits assigned to height. - */ -private const val MinFocusWidth = 0x2 - -/** - * The bit distribution when the focus of the bits should be on the width, and a maximal number of - * bits assigned to the width. - * - * 18 bits assigned to width, 13 bits assigned to height. - */ -private const val MaxFocusWidth = 0x3 - -/** - * The bit distribution when the focus of the bits should be on the height, but only a minimal - * difference in focus. - * - * 15 bits assigned to width, 16 bits assigned to height. - */ -private const val MinFocusHeight = 0x1 - -/** - * The bit distribution when the focus of the bits should be on the height, and a a maximal number - * of bits assigned to the height. - * - * 13 bits assigned to width, 18 bits assigned to height. - */ -private const val MaxFocusHeight = 0x0 - -/** - * The mask to retrieve the focus ([MinFocusWidth], [MaxFocusWidth], [MinFocusHeight], - * [MaxFocusHeight]). + * The mask to retrieve the focus: + * - MaxFocusHeight = 0x0. The bit distribution when the focus of the bits should be on the height, + * and a a maximal number of bits assigned to the height. 13 bits assigned to width, 18 bits + * assigned to height. + * - MinFocusHeight = 0x1. The bit distribution when the focus of the bits should be on the height, + * but only a minimal difference in focus. 15 bits assigned to width, 16 bits assigned to height. + * - MinFocusWidth = 0x2. The bit distribution when the focus of the bits should be on the width, + * but only a minimal difference in focus. 16 bits assigned to width, 15 bits assigned to height. + * - MaxFocusWidth = 0x3 .The bit distribution when the focus of the bits should be on the width, + * and a maximal number of bits assigned to the width. 18 bits assigned to width, 13 bits assigned + * to height. */ private const val FocusMask = 0x3L diff --git a/compose/ui/ui-util/api/current.txt b/compose/ui/ui-util/api/current.txt index e18cbf36face3..fbfa5075bc642 100644 --- a/compose/ui/ui-util/api/current.txt +++ b/compose/ui/ui-util/api/current.txt @@ -39,6 +39,7 @@ package androidx.compose.ui.util { method public static inline java.util.List fastDistinctBy(java.util.List, kotlin.jvm.functions.Function1 selector); method public static inline java.util.List fastFilter(java.util.List, kotlin.jvm.functions.Function1 predicate); method public static java.util.List fastFilterNotNull(java.util.List); + method public static inline java.util.List fastFilteredMap(java.util.List, kotlin.jvm.functions.Function1 predicate, kotlin.jvm.functions.Function1 transform); method public static inline T fastFirst(java.util.List, kotlin.jvm.functions.Function1 predicate); method public static inline T? fastFirstOrNull(java.util.List, kotlin.jvm.functions.Function1 predicate); method public static inline java.util.List fastFlatMap(java.util.List, kotlin.jvm.functions.Function1> transform); diff --git a/compose/ui/ui-util/api/desktop/ui-util.api b/compose/ui/ui-util/api/desktop/ui-util.api index 91920ef81fed6..dbf651388c49f 100644 --- a/compose/ui/ui-util/api/desktop/ui-util.api +++ b/compose/ui/ui-util/api/desktop/ui-util.api @@ -28,6 +28,7 @@ public final class androidx/compose/ui/util/ListUtilsKt { public static final fun fastDistinctBy (Ljava/util/List;Lkotlin/jvm/functions/Function1;)Ljava/util/List; public static final fun fastFilter (Ljava/util/List;Lkotlin/jvm/functions/Function1;)Ljava/util/List; public static final fun fastFilterNotNull (Ljava/util/List;)Ljava/util/List; + public static final fun fastFilteredMap (Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/util/List; public static final fun fastFirst (Ljava/util/List;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public static final fun fastFirstOrNull (Ljava/util/List;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public static final fun fastFlatMap (Ljava/util/List;Lkotlin/jvm/functions/Function1;)Ljava/util/List; diff --git a/compose/ui/ui-util/api/restricted_current.txt b/compose/ui/ui-util/api/restricted_current.txt index be90ec46b98be..3ef27e174743e 100644 --- a/compose/ui/ui-util/api/restricted_current.txt +++ b/compose/ui/ui-util/api/restricted_current.txt @@ -39,6 +39,7 @@ package androidx.compose.ui.util { method public static inline java.util.List fastDistinctBy(java.util.List, kotlin.jvm.functions.Function1 selector); method public static inline java.util.List fastFilter(java.util.List, kotlin.jvm.functions.Function1 predicate); method public static java.util.List fastFilterNotNull(java.util.List); + method public static inline java.util.List fastFilteredMap(java.util.List, kotlin.jvm.functions.Function1 predicate, kotlin.jvm.functions.Function1 transform); method public static inline T fastFirst(java.util.List, kotlin.jvm.functions.Function1 predicate); method public static inline T? fastFirstOrNull(java.util.List, kotlin.jvm.functions.Function1 predicate); method public static inline java.util.List fastFlatMap(java.util.List, kotlin.jvm.functions.Function1> transform); diff --git a/compose/ui/ui-util/proguard-rules.pro b/compose/ui/ui-util/proguard-rules.pro new file mode 100644 index 0000000000000..67d118b98fa5e --- /dev/null +++ b/compose/ui/ui-util/proguard-rules.pro @@ -0,0 +1,24 @@ +# Copyright (C) 2020 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. + +# Keep all the functions created to throw an exception. We don't want these functions to be +# inlined in any way, which R8 will do by default. The whole point of these functions is to +# reduce the amount of code generated at the call site. +-keep,allowshrinking,allowobfuscation class androidx.compose.**.* { + static void throw*Exception(...); + static void throw*ExceptionForNullCheck(...); + # For methods returning Nothing + static java.lang.Void throw*Exception(...); + static java.lang.Void throw*ExceptionForNullCheck(...); +} diff --git a/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt b/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt index 889ba5c291c50..ff8ae9b06d3ae 100644 --- a/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt +++ b/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt @@ -233,6 +233,26 @@ inline fun List.fastFilter(predicate: (T) -> Boolean): List { return target } +/** + * Returns a list containing only elements matching the given [predicate], applying the given + * [transform] function to each element. + * + * **Do not use for collections that come from public APIs**, since they may not support random + * access in an efficient way, and this method may actually be a lot slower. Only use for + * collections that are created by code we control and are known to support random access. + */ +@Suppress("BanInlineOptIn") // Treat Kotlin Contracts as non-experimental. +@OptIn(ExperimentalContracts::class) +inline fun List.fastFilteredMap(predicate: (T) -> Boolean, transform: (T) -> R): List { + contract { + callsInPlace(predicate) + callsInPlace(transform) + } + val target = ArrayList(size) + fastForEach { if (predicate(it)) target += transform(it) } + return target +} + /** * Accumulates value starting with [initial] value and applying [operation] from left to right to * current accumulator value and each element. diff --git a/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt b/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt index ace3e1ba61df3..68410c243258a 100644 --- a/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt +++ b/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt @@ -132,14 +132,23 @@ inline fun Long.fastCoerceAtMost(maximumValue: Long): Long { * Returns `true` if this float is a finite floating-point value; returns `false` otherwise (for * `NaN` and infinity). */ -inline fun Float.fastIsFinite(): Boolean = (toRawBits() and 0x7fffffff) < 0x7f800000 +inline fun Float.fastIsFinite(): Boolean { + // TODO: We can delegate back to Float.isFinite() when + // https://youtrack.jetbrains.com/issue/KT-70695 is fixed and Compose depends on the proper + // version of Kotlin + return (toRawBits() and 0x7fffffff) < 0x7f800000 +} /** * Returns `true` if this double is a finite floating-point value; returns `false` otherwise (for * `NaN` and infinity). */ -inline fun Double.fastIsFinite(): Boolean = - (toRawBits() and 0x7fffffff_ffffffffL) < 0x7ff00000_00000000L +inline fun Double.fastIsFinite(): Boolean { + // TODO: We can delegate back to Float.isFinite() when + // https://youtrack.jetbrains.com/issue/KT-70695 is fixed and Compose depends on the proper + // version of Kotlin + return (toRawBits() and 0x7fffffff_ffffffffL) < 0x7ff00000_00000000L +} /** * Fast, approximate cube root function. Returns the cube root of [x]; for any [x] `fastCbrt(-x) == diff --git a/compose/ui/ui/api/current.ignore b/compose/ui/ui/api/current.ignore index 8caaf36572d2f..97b7e7b00a5f9 100644 --- a/compose/ui/ui/api/current.ignore +++ b/compose/ui/ui/api/current.ignore @@ -7,6 +7,8 @@ AddedAbstractMethod: androidx.compose.ui.focus.FocusTargetModifierNode#setFocusa Added method androidx.compose.ui.focus.FocusTargetModifierNode.setFocusability(int) +BecameUnchecked: androidx.compose.ui.platform.AbstractComposeView#showLayoutBounds: + Removed property AbstractComposeView.showLayoutBounds from compatibility checked API surface BecameUnchecked: androidx.compose.ui.semantics.SemanticsProperties#InvisibleToUser: Removed property SemanticsProperties.InvisibleToUser from compatibility checked API surface BecameUnchecked: androidx.compose.ui.semantics.SemanticsProperties#getInvisibleToUser(): @@ -17,5 +19,11 @@ ChangedType: androidx.compose.ui.layout.MeasureResult#getAlignmentLines(): Method androidx.compose.ui.layout.MeasureResult.getAlignmentLines has changed return type from java.util.Map to java.util.Map -RemovedMethod: androidx.compose.ui.layout.LayoutCoordinates#transformToScreen(float[]): - Removed method androidx.compose.ui.layout.LayoutCoordinates.transformToScreen(float[]) +RemovedMethod: androidx.compose.ui.graphics.TransformOrigin#getPivotFractionX(): + Removed method androidx.compose.ui.graphics.TransformOrigin.getPivotFractionX() +RemovedMethod: androidx.compose.ui.graphics.TransformOrigin#getPivotFractionY(): + Removed method androidx.compose.ui.graphics.TransformOrigin.getPivotFractionY() +RemovedMethod: androidx.compose.ui.layout.ScaleFactor#getScaleX(): + Removed method androidx.compose.ui.layout.ScaleFactor.getScaleX() +RemovedMethod: androidx.compose.ui.layout.ScaleFactor#getScaleY(): + Removed method androidx.compose.ui.layout.ScaleFactor.getScaleY() diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt index 10fc5347a61f1..261fc8ceeeffa 100644 --- a/compose/ui/ui/api/current.txt +++ b/compose/ui/ui/api/current.txt @@ -10,14 +10,14 @@ package androidx.compose.ui { method public androidx.compose.ui.Alignment.Horizontal getRight(); method public androidx.compose.ui.Alignment getTopLeft(); method public androidx.compose.ui.Alignment getTopRight(); - property public final androidx.compose.ui.Alignment BottomLeft; - property public final androidx.compose.ui.Alignment BottomRight; - property public final androidx.compose.ui.Alignment CenterLeft; - property public final androidx.compose.ui.Alignment CenterRight; - property public final androidx.compose.ui.Alignment.Horizontal Left; - property public final androidx.compose.ui.Alignment.Horizontal Right; - property public final androidx.compose.ui.Alignment TopLeft; - property public final androidx.compose.ui.Alignment TopRight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomLeft; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomRight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterLeft; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterRight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal Left; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal Right; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopLeft; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopRight; field public static final androidx.compose.ui.AbsoluteAlignment INSTANCE; } @@ -42,21 +42,21 @@ package androidx.compose.ui { method public androidx.compose.ui.Alignment getTopCenter(); method public androidx.compose.ui.Alignment getTopEnd(); method public androidx.compose.ui.Alignment getTopStart(); - property public final androidx.compose.ui.Alignment.Vertical Bottom; - property public final androidx.compose.ui.Alignment BottomCenter; - property public final androidx.compose.ui.Alignment BottomEnd; - property public final androidx.compose.ui.Alignment BottomStart; - property public final androidx.compose.ui.Alignment Center; - property public final androidx.compose.ui.Alignment CenterEnd; - property public final androidx.compose.ui.Alignment.Horizontal CenterHorizontally; - property public final androidx.compose.ui.Alignment CenterStart; - property public final androidx.compose.ui.Alignment.Vertical CenterVertically; - property public final androidx.compose.ui.Alignment.Horizontal End; - property public final androidx.compose.ui.Alignment.Horizontal Start; - property public final androidx.compose.ui.Alignment.Vertical Top; - property public final androidx.compose.ui.Alignment TopCenter; - property public final androidx.compose.ui.Alignment TopEnd; - property public final androidx.compose.ui.Alignment TopStart; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Vertical Bottom; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomCenter; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomEnd; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomStart; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment Center; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterEnd; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal CenterHorizontally; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterStart; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Vertical CenterVertically; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal End; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal Start; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Vertical Top; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopCenter; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopEnd; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopStart; } @androidx.compose.runtime.Stable public static fun interface Alignment.Horizontal { @@ -132,8 +132,13 @@ package androidx.compose.ui { } @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public final class ComposeUiFlags { + property public final boolean NewNestedScrollFlingDispatchingEnabled; + property public final boolean isRectTrackingEnabled; + property public final boolean isSemanticAutofillEnabled; field public static final androidx.compose.ui.ComposeUiFlags INSTANCE; + field public static boolean NewNestedScrollFlingDispatchingEnabled; field public static boolean isRectTrackingEnabled; + field public static boolean isSemanticAutofillEnabled; } public final class ComposedModifierKt { @@ -216,6 +221,12 @@ package androidx.compose.ui.autofill { method public void requestAutofillForNode(androidx.compose.ui.autofill.AutofillNode autofillNode); } + public interface AutofillManager { + method public void cancel(); + method public void commit(); + method public void requestAutofillForActiveElement(); + } + public final class AutofillNode { ctor public AutofillNode(optional java.util.List autofillTypes, optional androidx.compose.ui.geometry.Rect? boundingBox, kotlin.jvm.functions.Function1? onFill); method public java.util.List getAutofillTypes(); @@ -276,6 +287,107 @@ package androidx.compose.ui.autofill { enum_constant public static final androidx.compose.ui.autofill.AutofillType Username; } + @kotlin.jvm.JvmInline public final value class ContentDataType { + ctor public ContentDataType(int dataType); + method public int getDataType(); + property public final int dataType; + field public static final androidx.compose.ui.autofill.ContentDataType.Companion Companion; + } + + public static final class ContentDataType.Companion { + method public int getDate(); + method public int getList(); + method public int getNone(); + method public int getText(); + method public int getToggle(); + property public final int Date; + property public final int List; + property public final int None; + property public final int Text; + property public final int Toggle; + } + + public final class ContentType { + ctor public ContentType(String contentHint); + method public operator androidx.compose.ui.autofill.ContentType plus(androidx.compose.ui.autofill.ContentType other); + field public static final androidx.compose.ui.autofill.ContentType.Companion Companion; + } + + public static final class ContentType.Companion { + method public androidx.compose.ui.autofill.ContentType getAddressAuxiliaryDetails(); + method public androidx.compose.ui.autofill.ContentType getAddressCountry(); + method public androidx.compose.ui.autofill.ContentType getAddressLocality(); + method public androidx.compose.ui.autofill.ContentType getAddressRegion(); + method public androidx.compose.ui.autofill.ContentType getAddressStreet(); + method public androidx.compose.ui.autofill.ContentType getBirthDateDay(); + method public androidx.compose.ui.autofill.ContentType getBirthDateFull(); + method public androidx.compose.ui.autofill.ContentType getBirthDateMonth(); + method public androidx.compose.ui.autofill.ContentType getBirthDateYear(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationDate(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationDay(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationMonth(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationYear(); + method public androidx.compose.ui.autofill.ContentType getCreditCardNumber(); + method public androidx.compose.ui.autofill.ContentType getCreditCardSecurityCode(); + method public androidx.compose.ui.autofill.ContentType getEmailAddress(); + method public androidx.compose.ui.autofill.ContentType getGender(); + method public androidx.compose.ui.autofill.ContentType getNewPassword(); + method public androidx.compose.ui.autofill.ContentType getNewUsername(); + method public androidx.compose.ui.autofill.ContentType getPassword(); + method public androidx.compose.ui.autofill.ContentType getPersonFirstName(); + method public androidx.compose.ui.autofill.ContentType getPersonFullName(); + method public androidx.compose.ui.autofill.ContentType getPersonLastName(); + method public androidx.compose.ui.autofill.ContentType getPersonMiddleInitial(); + method public androidx.compose.ui.autofill.ContentType getPersonMiddleName(); + method public androidx.compose.ui.autofill.ContentType getPersonNamePrefix(); + method public androidx.compose.ui.autofill.ContentType getPersonNameSuffix(); + method public androidx.compose.ui.autofill.ContentType getPhoneCountryCode(); + method public androidx.compose.ui.autofill.ContentType getPhoneNumber(); + method public androidx.compose.ui.autofill.ContentType getPhoneNumberDevice(); + method public androidx.compose.ui.autofill.ContentType getPhoneNumberNational(); + method public androidx.compose.ui.autofill.ContentType getPostalAddress(); + method public androidx.compose.ui.autofill.ContentType getPostalCode(); + method public androidx.compose.ui.autofill.ContentType getPostalCodeExtended(); + method public androidx.compose.ui.autofill.ContentType getSmsOtpCode(); + method public androidx.compose.ui.autofill.ContentType getUsername(); + property public final androidx.compose.ui.autofill.ContentType AddressAuxiliaryDetails; + property public final androidx.compose.ui.autofill.ContentType AddressCountry; + property public final androidx.compose.ui.autofill.ContentType AddressLocality; + property public final androidx.compose.ui.autofill.ContentType AddressRegion; + property public final androidx.compose.ui.autofill.ContentType AddressStreet; + property public final androidx.compose.ui.autofill.ContentType BirthDateDay; + property public final androidx.compose.ui.autofill.ContentType BirthDateFull; + property public final androidx.compose.ui.autofill.ContentType BirthDateMonth; + property public final androidx.compose.ui.autofill.ContentType BirthDateYear; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationDate; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationDay; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationMonth; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationYear; + property public final androidx.compose.ui.autofill.ContentType CreditCardNumber; + property public final androidx.compose.ui.autofill.ContentType CreditCardSecurityCode; + property public final androidx.compose.ui.autofill.ContentType EmailAddress; + property public final androidx.compose.ui.autofill.ContentType Gender; + property public final androidx.compose.ui.autofill.ContentType NewPassword; + property public final androidx.compose.ui.autofill.ContentType NewUsername; + property public final androidx.compose.ui.autofill.ContentType Password; + property public final androidx.compose.ui.autofill.ContentType PersonFirstName; + property public final androidx.compose.ui.autofill.ContentType PersonFullName; + property public final androidx.compose.ui.autofill.ContentType PersonLastName; + property public final androidx.compose.ui.autofill.ContentType PersonMiddleInitial; + property public final androidx.compose.ui.autofill.ContentType PersonMiddleName; + property public final androidx.compose.ui.autofill.ContentType PersonNamePrefix; + property public final androidx.compose.ui.autofill.ContentType PersonNameSuffix; + property public final androidx.compose.ui.autofill.ContentType PhoneCountryCode; + property public final androidx.compose.ui.autofill.ContentType PhoneNumber; + property public final androidx.compose.ui.autofill.ContentType PhoneNumberDevice; + property public final androidx.compose.ui.autofill.ContentType PhoneNumberNational; + property public final androidx.compose.ui.autofill.ContentType PostalAddress; + property public final androidx.compose.ui.autofill.ContentType PostalCode; + property public final androidx.compose.ui.autofill.ContentType PostalCodeExtended; + property public final androidx.compose.ui.autofill.ContentType SmsOtpCode; + property public final androidx.compose.ui.autofill.ContentType Username; + } + } package androidx.compose.ui.contentcapture { @@ -475,6 +587,12 @@ package androidx.compose.ui.focus { property public final int Up; } + public sealed interface FocusEnterExitScope { + method public void cancelFocus(); + method public int getRequestedFocusDirection(); + property public abstract int requestedFocusDirection; + } + @Deprecated @kotlin.jvm.JvmDefaultWithCompatibility public interface FocusEventModifier extends androidx.compose.ui.Modifier.Element { method @Deprecated public void onFocusEvent(androidx.compose.ui.focus.FocusState focusState); } @@ -539,10 +657,12 @@ package androidx.compose.ui.focus { method public boolean getCanFocus(); method public default androidx.compose.ui.focus.FocusRequester getDown(); method public default androidx.compose.ui.focus.FocusRequester getEnd(); - method public default kotlin.jvm.functions.Function1 getEnter(); - method public default kotlin.jvm.functions.Function1 getExit(); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 getEnter(); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 getExit(); method public default androidx.compose.ui.focus.FocusRequester getLeft(); method public default androidx.compose.ui.focus.FocusRequester getNext(); + method public default kotlin.jvm.functions.Function1 getOnEnter(); + method public default kotlin.jvm.functions.Function1 getOnExit(); method public default androidx.compose.ui.focus.FocusRequester getPrevious(); method public default androidx.compose.ui.focus.FocusRequester getRight(); method public default androidx.compose.ui.focus.FocusRequester getStart(); @@ -550,10 +670,12 @@ package androidx.compose.ui.focus { method public void setCanFocus(boolean); method public default void setDown(androidx.compose.ui.focus.FocusRequester); method public default void setEnd(androidx.compose.ui.focus.FocusRequester); - method public default void setEnter(kotlin.jvm.functions.Function1); - method public default void setExit(kotlin.jvm.functions.Function1); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default void setEnter(kotlin.jvm.functions.Function1); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default void setExit(kotlin.jvm.functions.Function1); method public default void setLeft(androidx.compose.ui.focus.FocusRequester); method public default void setNext(androidx.compose.ui.focus.FocusRequester); + method public default void setOnEnter(kotlin.jvm.functions.Function1); + method public default void setOnExit(kotlin.jvm.functions.Function1); method public default void setPrevious(androidx.compose.ui.focus.FocusRequester); method public default void setRight(androidx.compose.ui.focus.FocusRequester); method public default void setStart(androidx.compose.ui.focus.FocusRequester); @@ -561,10 +683,12 @@ package androidx.compose.ui.focus { property public abstract boolean canFocus; property public default androidx.compose.ui.focus.FocusRequester down; property public default androidx.compose.ui.focus.FocusRequester end; - property public default kotlin.jvm.functions.Function1 enter; - property public default kotlin.jvm.functions.Function1 exit; + property @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 enter; + property @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 exit; property public default androidx.compose.ui.focus.FocusRequester left; property public default androidx.compose.ui.focus.FocusRequester next; + property public default kotlin.jvm.functions.Function1 onEnter; + property public default kotlin.jvm.functions.Function1 onExit; property public default androidx.compose.ui.focus.FocusRequester previous; property public default androidx.compose.ui.focus.FocusRequester right; property public default androidx.compose.ui.focus.FocusRequester start; @@ -642,7 +766,8 @@ package androidx.compose.ui.focus { } public final class FocusRestorerKt { - method public static androidx.compose.ui.Modifier focusRestorer(androidx.compose.ui.Modifier, optional kotlin.jvm.functions.Function0? onRestoreFailed); + method public static androidx.compose.ui.Modifier focusRestorer(androidx.compose.ui.Modifier, optional androidx.compose.ui.focus.FocusRequester fallback); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public static androidx.compose.ui.Modifier focusRestorer(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function0? onRestoreFailed); } public interface FocusState { @@ -767,6 +892,7 @@ package androidx.compose.ui.graphics { method public static androidx.compose.ui.graphics.GraphicsLayerScope GraphicsLayerScope(); method public static long getDefaultShadowColor(); method @androidx.compose.runtime.Composable @androidx.compose.runtime.ComposableOpenTarget(index=0xffffffff) public static androidx.compose.ui.graphics.layer.GraphicsLayer rememberGraphicsLayer(); + property public static final float DefaultCameraDistance; property public static final long DefaultShadowColor; field public static final float DefaultCameraDistance = 8.0f; } @@ -775,8 +901,6 @@ package androidx.compose.ui.graphics { method @androidx.compose.runtime.Stable public inline operator float component1(); method @androidx.compose.runtime.Stable public inline operator float component2(); method public long copy(optional float pivotFractionX, optional float pivotFractionY); - method public float getPivotFractionX(); - method public float getPivotFractionY(); property public final float pivotFractionX; property public final float pivotFractionY; field public static final androidx.compose.ui.graphics.TransformOrigin.Companion Companion; @@ -895,10 +1019,24 @@ package androidx.compose.ui.graphics.vector { method public static long getDefaultTintColor(); method public static java.util.List getEmptyPath(); property public static final int DefaultFillType; + property public static final String DefaultGroupName; + property public static final String DefaultPathName; + property public static final float DefaultPivotX; + property public static final float DefaultPivotY; + property public static final float DefaultRotation; + property public static final float DefaultScaleX; + property public static final float DefaultScaleY; property public static final int DefaultStrokeLineCap; property public static final int DefaultStrokeLineJoin; + property public static final float DefaultStrokeLineMiter; + property public static final float DefaultStrokeLineWidth; property public static final int DefaultTintBlendMode; property public static final long DefaultTintColor; + property public static final float DefaultTranslationX; + property public static final float DefaultTranslationY; + property public static final float DefaultTrimPathEnd; + property public static final float DefaultTrimPathOffset; + property public static final float DefaultTrimPathStart; property public static final java.util.List EmptyPath; field public static final String DefaultGroupName = ""; field public static final String DefaultPathName = ""; @@ -930,6 +1068,7 @@ package androidx.compose.ui.graphics.vector { method @androidx.compose.runtime.Composable public static androidx.compose.ui.graphics.vector.VectorPainter rememberVectorPainter(androidx.compose.ui.graphics.vector.ImageVector image); method @androidx.compose.runtime.Composable @androidx.compose.runtime.ComposableOpenTarget(index=0xffffffff) public static androidx.compose.ui.graphics.vector.VectorPainter rememberVectorPainter(float defaultWidth, float defaultHeight, optional float viewportWidth, optional float viewportHeight, optional String name, optional long tintColor, optional int tintBlendMode, optional boolean autoMirror, kotlin.jvm.functions.Function2 content); method @Deprecated @androidx.compose.runtime.Composable @androidx.compose.runtime.ComposableOpenTarget(index=0xffffffff) public static androidx.compose.ui.graphics.vector.VectorPainter rememberVectorPainter(float defaultWidth, float defaultHeight, optional float viewportWidth, optional float viewportHeight, optional String name, optional long tintColor, optional int tintBlendMode, kotlin.jvm.functions.Function2 content); + property public static final String RootGroupName; field public static final String RootGroupName = "VectorRootGroup"; } @@ -1045,11 +1184,31 @@ package androidx.compose.ui.hapticfeedback { } public static final class HapticFeedbackType.Companion { + method public int getConfirm(); + method public int getContextClick(); + method public int getGestureEnd(); + method public int getGestureThresholdActivate(); method public int getLongPress(); + method public int getReject(); + method public int getSegmentFrequentTick(); + method public int getSegmentTick(); method public int getTextHandleMove(); + method public int getToggleOff(); + method public int getToggleOn(); + method public int getVirtualKey(); method public java.util.List values(); + property public final int Confirm; + property public final int ContextClick; + property public final int GestureEnd; + property public final int GestureThresholdActivate; property public final int LongPress; + property public final int Reject; + property public final int SegmentFrequentTick; + property public final int SegmentTick; property public final int TextHandleMove; + property public final int ToggleOff; + property public final int ToggleOn; + property public final int VirtualKey; } } @@ -1908,6 +2067,7 @@ package androidx.compose.ui.input.pointer { public final class PointerIconKt { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier pointerHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants); + method public static androidx.compose.ui.Modifier stylusHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants, optional androidx.compose.ui.node.DpTouchBoundsExpansion? touchBoundsExpansion); } public final class PointerIcon_androidKt { @@ -2117,6 +2277,7 @@ package androidx.compose.ui.layout { } public static final class AlignmentLine.Companion { + property public static final int Unspecified; } public final class AlignmentLineKt { @@ -2193,13 +2354,13 @@ package androidx.compose.ui.layout { method public androidx.compose.ui.layout.ContentScale getFit(); method public androidx.compose.ui.layout.ContentScale getInside(); method public androidx.compose.ui.layout.FixedScale getNone(); - property public final androidx.compose.ui.layout.ContentScale Crop; - property public final androidx.compose.ui.layout.ContentScale FillBounds; - property public final androidx.compose.ui.layout.ContentScale FillHeight; - property public final androidx.compose.ui.layout.ContentScale FillWidth; - property public final androidx.compose.ui.layout.ContentScale Fit; - property public final androidx.compose.ui.layout.ContentScale Inside; - property public final androidx.compose.ui.layout.FixedScale None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale Crop; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale FillBounds; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale FillHeight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale FillWidth; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale Fit; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale Inside; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.FixedScale None; } @androidx.compose.runtime.Immutable public final class FixedScale implements androidx.compose.ui.layout.ContentScale { @@ -2258,6 +2419,7 @@ package androidx.compose.ui.layout { method public long localToWindow(long relativeToLocal); method public default long screenToLocal(long relativeToScreen); method public default void transformFrom(androidx.compose.ui.layout.LayoutCoordinates sourceCoordinates, float[] matrix); + method public default void transformToScreen(float[] matrix); method public long windowToLocal(long relativeToWindow); property public default boolean introducesMotionFrameOfReference; property public abstract boolean isAttached; @@ -2276,7 +2438,6 @@ package androidx.compose.ui.layout { method public static long positionInRoot(androidx.compose.ui.layout.LayoutCoordinates); method public static long positionInWindow(androidx.compose.ui.layout.LayoutCoordinates); method public static long positionOnScreen(androidx.compose.ui.layout.LayoutCoordinates); - method public static void transformToScreen(androidx.compose.ui.layout.LayoutCoordinates, float[] matrix); } public final class LayoutIdKt { @@ -2338,6 +2499,7 @@ package androidx.compose.ui.layout { method public androidx.compose.ui.layout.LayoutCoordinates getLookaheadScopeCoordinates(androidx.compose.ui.layout.Placeable.PlacementScope); method public default long localLookaheadPositionOf(androidx.compose.ui.layout.LayoutCoordinates, androidx.compose.ui.layout.LayoutCoordinates sourceCoordinates, optional long relativeToSource, optional boolean includeMotionFrameOfReference); method public androidx.compose.ui.layout.LayoutCoordinates toLookaheadCoordinates(androidx.compose.ui.layout.LayoutCoordinates); + property public androidx.compose.ui.layout.LayoutCoordinates lookaheadScopeCoordinates; } public final class LookaheadScopeKt { @@ -2421,6 +2583,11 @@ package androidx.compose.ui.layout { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onPlaced(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1 onPlaced); } + public final class OnRectChangedModifierKt { + method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onRectChanged(androidx.compose.ui.Modifier, optional int throttleMs, optional int debounceMs, kotlin.jvm.functions.Function1 callback); + method public static kotlinx.coroutines.DisposableHandle registerOnRectChanged(androidx.compose.ui.node.DelegatableNode, int throttleMs, int debounceMs, kotlin.jvm.functions.Function1 callback); + } + @kotlin.jvm.JvmDefaultWithCompatibility public interface OnRemeasuredModifier extends androidx.compose.ui.Modifier.Element { method public void onRemeasured(long size); } @@ -2520,8 +2687,6 @@ package androidx.compose.ui.layout { method public long copy(optional float scaleX, optional float scaleY); method @androidx.compose.runtime.Stable public operator long div(float operand); method public long getPackedValue(); - method public inline float getScaleX(); - method public inline float getScaleY(); method @androidx.compose.runtime.Stable public operator long times(float operand); property public final long packedValue; property @androidx.compose.runtime.Stable public final inline float scaleX; @@ -2531,7 +2696,7 @@ package androidx.compose.ui.layout { public static final class ScaleFactor.Companion { method public long getUnspecified(); - property public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Unspecified; } public final class ScaleFactorKt { @@ -2626,6 +2791,7 @@ package androidx.compose.ui.modifier { method public default T getCurrent(androidx.compose.ui.modifier.ModifierLocal); method public default androidx.compose.ui.modifier.ModifierLocalMap getProvidedValues(); method public default void provide(androidx.compose.ui.modifier.ModifierLocal key, T value); + property public T current; property public default androidx.compose.ui.modifier.ModifierLocalMap providedValues; } @@ -2652,6 +2818,7 @@ package androidx.compose.ui.modifier { public interface ModifierLocalReadScope { method public T getCurrent(androidx.compose.ui.modifier.ModifierLocal); + property public T current; } @androidx.compose.runtime.Stable public final class ProvidableModifierLocal extends androidx.compose.ui.modifier.ModifierLocal { @@ -2694,6 +2861,32 @@ package androidx.compose.ui.node { method protected final void undelegate(androidx.compose.ui.node.DelegatableNode instance); } + public final class DpTouchBoundsExpansion { + ctor public DpTouchBoundsExpansion(float start, float top, float end, float bottom, boolean isLayoutDirectionAware); + method public float component1-D9Ej5fM(); + method public float component2-D9Ej5fM(); + method public float component3-D9Ej5fM(); + method public float component4-D9Ej5fM(); + method public boolean component5(); + method public androidx.compose.ui.node.DpTouchBoundsExpansion copy-lDy3nrA(float start, float top, float end, float bottom, boolean isLayoutDirectionAware); + method public float getBottom(); + method public float getEnd(); + method public float getStart(); + method public float getTop(); + method public boolean isLayoutDirectionAware(); + method public long roundToTouchBoundsExpansion(androidx.compose.ui.unit.Density density); + property public final float bottom; + property public final float end; + property public final boolean isLayoutDirectionAware; + property public final float start; + property public final float top; + field public static final androidx.compose.ui.node.DpTouchBoundsExpansion.Companion Companion; + } + + public static final class DpTouchBoundsExpansion.Companion { + method public androidx.compose.ui.node.DpTouchBoundsExpansion Absolute(optional float left, optional float top, optional float right, optional float bottom); + } + public interface DrawModifierNode extends androidx.compose.ui.node.DelegatableNode { method public void draw(androidx.compose.ui.graphics.drawscope.ContentDrawScope); method public default void onMeasureResultChanged(); @@ -2766,11 +2959,13 @@ package androidx.compose.ui.node { } public interface PointerInputModifierNode extends androidx.compose.ui.node.DelegatableNode { + method public default long getTouchBoundsExpansion(); method public default boolean interceptOutOfBoundsChildEvents(); method public void onCancelPointerInput(); method public void onPointerEvent(androidx.compose.ui.input.pointer.PointerEvent pointerEvent, androidx.compose.ui.input.pointer.PointerEventPass pass, long bounds); method public default void onViewConfigurationChange(); method public default boolean sharePointerInputWithSiblings(); + property public default long touchBoundsExpansion; } public final class Ref { @@ -2805,6 +3000,26 @@ package androidx.compose.ui.node { method public static void invalidateSemantics(androidx.compose.ui.node.SemanticsModifierNode); } + @kotlin.jvm.JvmInline public final value class TouchBoundsExpansion { + property public final int bottom; + property public final int end; + property public final boolean isLayoutDirectionAware; + property public final int start; + property public final int top; + field public static final androidx.compose.ui.node.TouchBoundsExpansion.Companion Companion; + } + + public static final class TouchBoundsExpansion.Companion { + method public long Absolute(optional int left, optional int top, optional int right, optional int bottom); + method public long getNone(); + property public final long None; + } + + public final class TouchBoundsExpansionKt { + method public static androidx.compose.ui.node.DpTouchBoundsExpansion DpTouchBoundsExpansion(optional float start, optional float top, optional float end, optional float bottom); + method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom); + } + public interface TraversableNode extends androidx.compose.ui.node.DelegatableNode { method public Object getTraverseKey(); property public abstract Object traverseKey; @@ -2852,7 +3067,7 @@ package androidx.compose.ui.platform { method public final void setViewCompositionStrategy(androidx.compose.ui.platform.ViewCompositionStrategy strategy); property public final boolean hasComposition; property protected boolean shouldCreateCompositionOnAttachedToWindow; - property public final boolean showLayoutBounds; + property @SuppressCompatibility @androidx.compose.ui.InternalComposeUiApi public final boolean showLayoutBounds; } @kotlin.jvm.JvmDefaultWithCompatibility public interface AccessibilityManager { @@ -2966,6 +3181,7 @@ package androidx.compose.ui.platform { public final class CompositionLocalsKt { method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAccessibilityManager(); method @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofill(); + method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofillManager(); method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofillTree(); method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalClipboardManager(); method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalCursorBlinkEnabled(); @@ -2985,6 +3201,7 @@ package androidx.compose.ui.platform { method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalWindowInfo(); property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAccessibilityManager; property @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofill; + property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofillManager; property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofillTree; property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalClipboardManager; property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalCursorBlinkEnabled; @@ -3109,6 +3326,7 @@ package androidx.compose.ui.platform { method public androidx.compose.ui.platform.TextToolbarStatus getStatus(); method public void hide(); method public void showMenu(androidx.compose.ui.geometry.Rect rect, optional kotlin.jvm.functions.Function0? onCopyRequested, optional kotlin.jvm.functions.Function0? onPasteRequested, optional kotlin.jvm.functions.Function0? onCutRequested, optional kotlin.jvm.functions.Function0? onSelectAllRequested); + method public default void showMenu(androidx.compose.ui.geometry.Rect rect, optional kotlin.jvm.functions.Function0? onCopyRequested, optional kotlin.jvm.functions.Function0? onPasteRequested, optional kotlin.jvm.functions.Function0? onCutRequested, optional kotlin.jvm.functions.Function0? onSelectAllRequested, optional kotlin.jvm.functions.Function0? onAutofillRequested); property public abstract androidx.compose.ui.platform.TextToolbarStatus status; } @@ -3209,12 +3427,14 @@ package androidx.compose.ui.platform { public static final class ViewRootForTest.Companion { method public kotlin.jvm.functions.Function1? getOnViewCreatedCallback(); method public void setOnViewCreatedCallback(kotlin.jvm.functions.Function1?); - property public final kotlin.jvm.functions.Function1? onViewCreatedCallback; + property @VisibleForTesting public final kotlin.jvm.functions.Function1? onViewCreatedCallback; } @androidx.compose.runtime.Stable public interface WindowInfo { + method public default long getContainerSize(); method public default int getKeyboardModifiers(); method public boolean isWindowFocused(); + property public default long containerSize; property public abstract boolean isWindowFocused; property public default int keyboardModifiers; } @@ -3400,6 +3620,7 @@ package androidx.compose.ui.semantics { method public androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> getGetScrollViewportLength(); method public androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> getGetTextLayoutResult(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getInsertTextAtCursor(); + method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnAutofillText(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnClick(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnImeAction(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnLongClick(); @@ -3428,6 +3649,7 @@ package androidx.compose.ui.semantics { property public final androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> GetScrollViewportLength; property public final androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> GetTextLayoutResult; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> InsertTextAtCursor; + property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnAutofillText; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnClick; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnImeAction; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnLongClick; @@ -3531,7 +3753,9 @@ package androidx.compose.ui.semantics { public final class SemanticsProperties { method public androidx.compose.ui.semantics.SemanticsPropertyKey getCollectionInfo(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getCollectionItemInfo(); + method public androidx.compose.ui.semantics.SemanticsPropertyKey getContentDataType(); method public androidx.compose.ui.semantics.SemanticsPropertyKey> getContentDescription(); + method public androidx.compose.ui.semantics.SemanticsPropertyKey getContentType(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getDisabled(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getEditableText(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getError(); @@ -3567,7 +3791,9 @@ package androidx.compose.ui.semantics { method public androidx.compose.ui.semantics.SemanticsPropertyKey getVerticalScrollAxisRange(); property public final androidx.compose.ui.semantics.SemanticsPropertyKey CollectionInfo; property public final androidx.compose.ui.semantics.SemanticsPropertyKey CollectionItemInfo; + property public final androidx.compose.ui.semantics.SemanticsPropertyKey ContentDataType; property public final androidx.compose.ui.semantics.SemanticsPropertyKey> ContentDescription; + property public final androidx.compose.ui.semantics.SemanticsPropertyKey ContentType; property public final androidx.compose.ui.semantics.SemanticsPropertyKey Disabled; property public final androidx.compose.ui.semantics.SemanticsPropertyKey EditableText; property public final androidx.compose.ui.semantics.SemanticsPropertyKey Error; @@ -3622,7 +3848,9 @@ package androidx.compose.ui.semantics { method public static void expand(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function0? action); method public static androidx.compose.ui.semantics.CollectionInfo getCollectionInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static androidx.compose.ui.semantics.CollectionItemInfo getCollectionItemInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method public static int getContentDataType(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static String getContentDescription(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method public static androidx.compose.ui.autofill.ContentType getContentType(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static java.util.List getCustomActions(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static androidx.compose.ui.text.AnnotatedString getEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static boolean getFocused(androidx.compose.ui.semantics.SemanticsPropertyReceiver); @@ -3653,6 +3881,7 @@ package androidx.compose.ui.semantics { method public static boolean isEditable(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static boolean isShowingTextSubstitution(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static boolean isTraversalGroup(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method public static void onAutofillText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function1? action); method public static void onClick(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function0? action); method public static void onImeAction(androidx.compose.ui.semantics.SemanticsPropertyReceiver, int imeActionType, optional String? label, kotlin.jvm.functions.Function0? action); method public static void onLongClick(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function0? action); @@ -3672,7 +3901,9 @@ package androidx.compose.ui.semantics { method public static void setCollectionInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.CollectionInfo); method public static void setCollectionItemInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.CollectionItemInfo); method @Deprecated public static void setContainer(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); + method public static void setContentDataType(androidx.compose.ui.semantics.SemanticsPropertyReceiver, int); method public static void setContentDescription(androidx.compose.ui.semantics.SemanticsPropertyReceiver, String); + method public static void setContentType(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.autofill.ContentType); method public static void setCustomActions(androidx.compose.ui.semantics.SemanticsPropertyReceiver, java.util.List); method public static void setEditable(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); method public static void setEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.text.AnnotatedString); @@ -3700,11 +3931,38 @@ package androidx.compose.ui.semantics { method public static void setTraversalIndex(androidx.compose.ui.semantics.SemanticsPropertyReceiver, float); method public static void setVerticalScrollAxisRange(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.ScrollAxisRange); method public static void showTextSubstitution(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function1? action); + property public final androidx.compose.ui.semantics.CollectionInfo collectionInfo; + property public final androidx.compose.ui.semantics.CollectionItemInfo collectionItemInfo; + property public final int contentDataType; + property public final androidx.compose.ui.autofill.ContentType contentType; + property public final java.util.List customActions; + property public final androidx.compose.ui.text.AnnotatedString editableText; + property public final boolean focused; + property public final androidx.compose.ui.semantics.ScrollAxisRange horizontalScrollAxisRange; + property @Deprecated public final int imeAction; + property @Deprecated public final boolean isContainer; + property public final boolean isEditable; + property public final boolean isShowingTextSubstitution; + property public final boolean isTraversalGroup; + property public final int liveRegion; + property public final int maxTextLength; + property public final String paneTitle; + property public final androidx.compose.ui.semantics.ProgressBarRangeInfo progressBarRangeInfo; + property public final int role; + property public final boolean selected; + property public final String stateDescription; + property public final String testTag; + property public final long textSelectionRange; + property public final androidx.compose.ui.text.AnnotatedString textSubstitution; + property public final androidx.compose.ui.state.ToggleableState toggleableState; + property public final float traversalIndex; + property public final androidx.compose.ui.semantics.ScrollAxisRange verticalScrollAxisRange; } public final class SemanticsProperties_androidKt { method public static boolean getTestTagsAsResourceId(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static void setTestTagsAsResourceId(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); + property public final boolean testTagsAsResourceId; } public final class SemanticsPropertyKey { @@ -3722,6 +3980,29 @@ package androidx.compose.ui.semantics { } +package androidx.compose.ui.spatial { + + public final class RectInfo { + method public int getHeight(); + method public long getPositionInRoot(); + method public long getPositionInScreen(); + method public long getPositionInWindow(); + method public androidx.compose.ui.unit.IntRect getRootRect(); + method public androidx.compose.ui.unit.IntRect getScreenRect(); + method public int getWidth(); + method public androidx.compose.ui.unit.IntRect getWindowRect(); + property public final int height; + property public final long positionInRoot; + property public final long positionInScreen; + property public final long positionInWindow; + property public final androidx.compose.ui.unit.IntRect rootRect; + property public final androidx.compose.ui.unit.IntRect screenRect; + property public final int width; + property public final androidx.compose.ui.unit.IntRect windowRect; + } + +} + package androidx.compose.ui.state { public enum ToggleableState { diff --git a/compose/ui/ui/api/desktop/ui.api b/compose/ui/ui/api/desktop/ui.api index 4cd42f390125e..6fab3afecbfdb 100644 --- a/compose/ui/ui/api/desktop/ui.api +++ b/compose/ui/ui/api/desktop/ui.api @@ -140,7 +140,9 @@ public final class androidx/compose/ui/ComposableSingletons$ImageComposeScene_sk public final class androidx/compose/ui/ComposeUiFlags { public static final field $stable I public static final field INSTANCE Landroidx/compose/ui/ComposeUiFlags; + public static field NewNestedScrollFlingDispatchingEnabled Z public static field isRectTrackingEnabled Z + public static field isSemanticAutofillEnabled Z } public final class androidx/compose/ui/ComposedModifierKt { @@ -305,6 +307,12 @@ public abstract interface class androidx/compose/ui/autofill/Autofill { public abstract fun requestAutofillForNode (Landroidx/compose/ui/autofill/AutofillNode;)V } +public abstract interface class androidx/compose/ui/autofill/AutofillManager { + public abstract fun cancel ()V + public abstract fun commit ()V + public abstract fun requestAutofillForActiveElement ()V +} + public final class androidx/compose/ui/autofill/AutofillNode { public static final field $stable I public fun (Ljava/util/List;Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function1;)V @@ -368,6 +376,74 @@ public final class androidx/compose/ui/autofill/AutofillType : java/lang/Enum { public static fun values ()[Landroidx/compose/ui/autofill/AutofillType; } +public final class androidx/compose/ui/autofill/ContentDataType { + public static final field Companion Landroidx/compose/ui/autofill/ContentDataType$Companion; + public static final synthetic fun box-impl (I)Landroidx/compose/ui/autofill/ContentDataType; + public static fun constructor-impl (I)I + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (ILjava/lang/Object;)Z + public static final fun equals-impl0 (II)Z + public final fun getDataType ()I + public fun hashCode ()I + public static fun hashCode-impl (I)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (I)Ljava/lang/String; + public final synthetic fun unbox-impl ()I +} + +public final class androidx/compose/ui/autofill/ContentDataType$Companion { + public final fun getDate-A48pgw8 ()I + public final fun getList-A48pgw8 ()I + public final fun getNone-A48pgw8 ()I + public final fun getText-A48pgw8 ()I + public final fun getToggle-A48pgw8 ()I +} + +public final class androidx/compose/ui/autofill/ContentType { + public static final field $stable I + public static final field Companion Landroidx/compose/ui/autofill/ContentType$Companion; + public fun (Ljava/lang/String;)V +} + +public final class androidx/compose/ui/autofill/ContentType$Companion { + public final fun getAddressAuxiliaryDetails ()Landroidx/compose/ui/autofill/ContentType; + public final fun getAddressCountry ()Landroidx/compose/ui/autofill/ContentType; + public final fun getAddressLocality ()Landroidx/compose/ui/autofill/ContentType; + public final fun getAddressRegion ()Landroidx/compose/ui/autofill/ContentType; + public final fun getAddressStreet ()Landroidx/compose/ui/autofill/ContentType; + public final fun getBirthDateDay ()Landroidx/compose/ui/autofill/ContentType; + public final fun getBirthDateFull ()Landroidx/compose/ui/autofill/ContentType; + public final fun getBirthDateMonth ()Landroidx/compose/ui/autofill/ContentType; + public final fun getBirthDateYear ()Landroidx/compose/ui/autofill/ContentType; + public final fun getCreditCardExpirationDate ()Landroidx/compose/ui/autofill/ContentType; + public final fun getCreditCardExpirationDay ()Landroidx/compose/ui/autofill/ContentType; + public final fun getCreditCardExpirationMonth ()Landroidx/compose/ui/autofill/ContentType; + public final fun getCreditCardExpirationYear ()Landroidx/compose/ui/autofill/ContentType; + public final fun getCreditCardNumber ()Landroidx/compose/ui/autofill/ContentType; + public final fun getCreditCardSecurityCode ()Landroidx/compose/ui/autofill/ContentType; + public final fun getEmailAddress ()Landroidx/compose/ui/autofill/ContentType; + public final fun getGender ()Landroidx/compose/ui/autofill/ContentType; + public final fun getNewPassword ()Landroidx/compose/ui/autofill/ContentType; + public final fun getNewUsername ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPassword ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPersonFirstName ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPersonFullName ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPersonLastName ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPersonMiddleInitial ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPersonMiddleName ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPersonNamePrefix ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPersonNameSuffix ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPhoneCountryCode ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPhoneNumber ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPhoneNumberDevice ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPhoneNumberNational ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPostalAddress ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPostalCode ()Landroidx/compose/ui/autofill/ContentType; + public final fun getPostalCodeExtended ()Landroidx/compose/ui/autofill/ContentType; + public final fun getSmsOtpCode ()Landroidx/compose/ui/autofill/ContentType; + public final fun getUsername ()Landroidx/compose/ui/autofill/ContentType; +} + public final class androidx/compose/ui/awt/AwtEvents_desktopKt { public static final fun getAwtEventOrNull (Landroidx/compose/ui/input/pointer/PointerEvent;)Ljava/awt/event/MouseEvent; public static final fun getAwtEventOrNull-ZmokQxo (Ljava/lang/Object;)Ljava/awt/event/KeyEvent; @@ -739,6 +815,11 @@ public final class androidx/compose/ui/focus/FocusDirection$Companion { public final fun getUp-dhqQ-8s ()I } +public abstract interface class androidx/compose/ui/focus/FocusEnterExitScope { + public abstract fun cancelFocus ()V + public abstract fun getRequestedFocusDirection-dhqQ-8s ()I +} + public abstract interface class androidx/compose/ui/focus/FocusEventModifier : androidx/compose/ui/Modifier$Element { public abstract fun onFocusEvent (Landroidx/compose/ui/focus/FocusState;)V } @@ -821,6 +902,8 @@ public abstract interface class androidx/compose/ui/focus/FocusProperties { public fun getExit ()Lkotlin/jvm/functions/Function1; public fun getLeft ()Landroidx/compose/ui/focus/FocusRequester; public fun getNext ()Landroidx/compose/ui/focus/FocusRequester; + public fun getOnEnter ()Lkotlin/jvm/functions/Function1; + public fun getOnExit ()Lkotlin/jvm/functions/Function1; public fun getPrevious ()Landroidx/compose/ui/focus/FocusRequester; public fun getRight ()Landroidx/compose/ui/focus/FocusRequester; public fun getStart ()Landroidx/compose/ui/focus/FocusRequester; @@ -832,6 +915,8 @@ public abstract interface class androidx/compose/ui/focus/FocusProperties { public fun setExit (Lkotlin/jvm/functions/Function1;)V public fun setLeft (Landroidx/compose/ui/focus/FocusRequester;)V public fun setNext (Landroidx/compose/ui/focus/FocusRequester;)V + public fun setOnEnter (Lkotlin/jvm/functions/Function1;)V + public fun setOnExit (Lkotlin/jvm/functions/Function1;)V public fun setPrevious (Landroidx/compose/ui/focus/FocusRequester;)V public fun setRight (Landroidx/compose/ui/focus/FocusRequester;)V public fun setStart (Landroidx/compose/ui/focus/FocusRequester;)V @@ -916,8 +1001,9 @@ public final class androidx/compose/ui/focus/FocusRequesterModifierNodeKt { } public final class androidx/compose/ui/focus/FocusRestorerKt { + public static final fun focusRestorer (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/focus/FocusRequester;)Landroidx/compose/ui/Modifier; public static final fun focusRestorer (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; - public static synthetic fun focusRestorer$default (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static synthetic fun focusRestorer$default (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/focus/FocusRequester;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; } public abstract interface class androidx/compose/ui/focus/FocusState { @@ -1347,8 +1433,18 @@ public final class androidx/compose/ui/hapticfeedback/HapticFeedbackType { } public final class androidx/compose/ui/hapticfeedback/HapticFeedbackType$Companion { + public final fun getConfirm-5zf0vsI ()I + public final fun getContextClick-5zf0vsI ()I + public final fun getGestureEnd-5zf0vsI ()I + public final fun getGestureThresholdActivate-5zf0vsI ()I public final fun getLongPress-5zf0vsI ()I + public final fun getReject-5zf0vsI ()I + public final fun getSegmentFrequentTick-5zf0vsI ()I + public final fun getSegmentTick-5zf0vsI ()I public final fun getTextHandleMove-5zf0vsI ()I + public final fun getToggleOff-5zf0vsI ()I + public final fun getToggleOn-5zf0vsI ()I + public final fun getVirtualKey-5zf0vsI ()I public final fun values ()Ljava/util/List; } @@ -2081,6 +2177,8 @@ public final class androidx/compose/ui/input/pointer/PointerIcon$Companion { public final class androidx/compose/ui/input/pointer/PointerIconKt { public static final fun pointerHoverIcon (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/input/pointer/PointerIcon;Z)Landroidx/compose/ui/Modifier; public static synthetic fun pointerHoverIcon$default (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/input/pointer/PointerIcon;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static final fun stylusHoverIcon (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/input/pointer/PointerIcon;ZLandroidx/compose/ui/node/DpTouchBoundsExpansion;)Landroidx/compose/ui/Modifier; + public static synthetic fun stylusHoverIcon$default (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/input/pointer/PointerIcon;ZLandroidx/compose/ui/node/DpTouchBoundsExpansion;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; } public final class androidx/compose/ui/input/pointer/PointerIcon_desktopKt { @@ -2460,6 +2558,7 @@ public abstract interface class androidx/compose/ui/layout/LayoutCoordinates { public abstract fun localToWindow-MK-Hz9U (J)J public fun screenToLocal-MK-Hz9U (J)J public fun transformFrom-EL8BTi8 (Landroidx/compose/ui/layout/LayoutCoordinates;[F)V + public fun transformToScreen-58bKbWc ([F)V public abstract fun windowToLocal-MK-Hz9U (J)J } @@ -2471,6 +2570,7 @@ public final class androidx/compose/ui/layout/LayoutCoordinates$DefaultImpls { public static fun localToScreen-MK-Hz9U (Landroidx/compose/ui/layout/LayoutCoordinates;J)J public static fun screenToLocal-MK-Hz9U (Landroidx/compose/ui/layout/LayoutCoordinates;J)J public static fun transformFrom-EL8BTi8 (Landroidx/compose/ui/layout/LayoutCoordinates;Landroidx/compose/ui/layout/LayoutCoordinates;[F)V + public static fun transformToScreen-58bKbWc (Landroidx/compose/ui/layout/LayoutCoordinates;[F)V } public final class androidx/compose/ui/layout/LayoutCoordinatesKt { @@ -2674,6 +2774,12 @@ public final class androidx/compose/ui/layout/OnPlacedModifierKt { public static final fun onPlaced (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;)Landroidx/compose/ui/Modifier; } +public final class androidx/compose/ui/layout/OnRectChangedModifierKt { + public static final fun onRectChanged (Landroidx/compose/ui/Modifier;IILkotlin/jvm/functions/Function1;)Landroidx/compose/ui/Modifier; + public static synthetic fun onRectChanged$default (Landroidx/compose/ui/Modifier;IILkotlin/jvm/functions/Function1;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static final fun registerOnRectChanged (Landroidx/compose/ui/node/DelegatableNode;IILkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/DisposableHandle; +} + public abstract interface class androidx/compose/ui/layout/OnRemeasuredModifier : androidx/compose/ui/Modifier$Element { public abstract fun onRemeasured-ozmzZPI (J)V } @@ -3033,6 +3139,33 @@ public abstract class androidx/compose/ui/node/DelegatingNode : androidx/compose protected final fun undelegate (Landroidx/compose/ui/node/DelegatableNode;)V } +public final class androidx/compose/ui/node/DpTouchBoundsExpansion { + public static final field $stable I + public static final field Companion Landroidx/compose/ui/node/DpTouchBoundsExpansion$Companion; + public synthetic fun (FFFFZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-D9Ej5fM ()F + public final fun component2-D9Ej5fM ()F + public final fun component3-D9Ej5fM ()F + public final fun component4-D9Ej5fM ()F + public final fun component5 ()Z + public final fun copy-lDy3nrA (FFFFZ)Landroidx/compose/ui/node/DpTouchBoundsExpansion; + public static synthetic fun copy-lDy3nrA$default (Landroidx/compose/ui/node/DpTouchBoundsExpansion;FFFFZILjava/lang/Object;)Landroidx/compose/ui/node/DpTouchBoundsExpansion; + public fun equals (Ljava/lang/Object;)Z + public final fun getBottom-D9Ej5fM ()F + public final fun getEnd-D9Ej5fM ()F + public final fun getStart-D9Ej5fM ()F + public final fun getTop-D9Ej5fM ()F + public fun hashCode ()I + public final fun isLayoutDirectionAware ()Z + public final fun roundToTouchBoundsExpansion-TW6G1oQ (Landroidx/compose/ui/unit/Density;)J + public fun toString ()Ljava/lang/String; +} + +public final class androidx/compose/ui/node/DpTouchBoundsExpansion$Companion { + public final fun Absolute-a9UjIt4 (FFFF)Landroidx/compose/ui/node/DpTouchBoundsExpansion; + public static synthetic fun Absolute-a9UjIt4$default (Landroidx/compose/ui/node/DpTouchBoundsExpansion$Companion;FFFFILjava/lang/Object;)Landroidx/compose/ui/node/DpTouchBoundsExpansion; +} + public abstract interface class androidx/compose/ui/node/DrawModifierNode : androidx/compose/ui/node/DelegatableNode { public abstract fun draw (Landroidx/compose/ui/graphics/drawscope/ContentDrawScope;)V public fun onMeasureResultChanged ()V @@ -3103,6 +3236,7 @@ public final class androidx/compose/ui/node/ParentDataModifierNodeKt { } public abstract interface class androidx/compose/ui/node/PointerInputModifierNode : androidx/compose/ui/node/DelegatableNode { + public fun getTouchBoundsExpansion-RZrCHBk ()J public fun interceptOutOfBoundsChildEvents ()Z public abstract fun onCancelPointerInput ()V public fun onDensityChange ()V @@ -3138,6 +3272,37 @@ public final class androidx/compose/ui/node/SemanticsModifierNodeKt { public static final fun invalidateSemantics (Landroidx/compose/ui/node/SemanticsModifierNode;)V } +public final class androidx/compose/ui/node/TouchBoundsExpansion { + public static final field Companion Landroidx/compose/ui/node/TouchBoundsExpansion$Companion; + public static final synthetic fun box-impl (J)Landroidx/compose/ui/node/TouchBoundsExpansion; + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (JLjava/lang/Object;)Z + public static final fun equals-impl0 (JJ)Z + public static final fun getBottom-impl (J)I + public static final fun getEnd-impl (J)I + public static final fun getStart-impl (J)I + public static final fun getTop-impl (J)I + public fun hashCode ()I + public static fun hashCode-impl (J)I + public static final fun isLayoutDirectionAware-impl (J)Z + public fun toString ()Ljava/lang/String; + public static fun toString-impl (J)Ljava/lang/String; + public final synthetic fun unbox-impl ()J +} + +public final class androidx/compose/ui/node/TouchBoundsExpansion$Companion { + public final fun Absolute-vsh68fg (IIII)J + public static synthetic fun Absolute-vsh68fg$default (Landroidx/compose/ui/node/TouchBoundsExpansion$Companion;IIIIILjava/lang/Object;)J + public final fun getNone-RZrCHBk ()J +} + +public final class androidx/compose/ui/node/TouchBoundsExpansionKt { + public static final fun DpTouchBoundsExpansion-a9UjIt4 (FFFF)Landroidx/compose/ui/node/DpTouchBoundsExpansion; + public static synthetic fun DpTouchBoundsExpansion-a9UjIt4$default (FFFFILjava/lang/Object;)Landroidx/compose/ui/node/DpTouchBoundsExpansion; + public static final fun TouchBoundsExpansion (IIII)J + public static synthetic fun TouchBoundsExpansion$default (IIIIILjava/lang/Object;)J +} + public abstract interface class androidx/compose/ui/node/TraversableNode : androidx/compose/ui/node/DelegatableNode { public static final field Companion Landroidx/compose/ui/node/TraversableNode$Companion; public abstract fun getTraverseKey ()Ljava/lang/Object; @@ -3198,6 +3363,7 @@ public abstract interface class androidx/compose/ui/platform/ClipboardManager { public final class androidx/compose/ui/platform/CompositionLocalsKt { public static final fun getLocalAccessibilityManager ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalAutofill ()Landroidx/compose/runtime/ProvidableCompositionLocal; + public static final fun getLocalAutofillManager ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalAutofillTree ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalClipboardManager ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalCursorBlinkEnabled ()Landroidx/compose/runtime/ProvidableCompositionLocal; @@ -3445,11 +3611,15 @@ public abstract interface class androidx/compose/ui/platform/TextToolbar { public abstract fun getStatus ()Landroidx/compose/ui/platform/TextToolbarStatus; public abstract fun hide ()V public abstract fun showMenu (Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public fun showMenu (Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V public static synthetic fun showMenu$default (Landroidx/compose/ui/platform/TextToolbar;Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun showMenu$default (Landroidx/compose/ui/platform/TextToolbar;Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V } public final class androidx/compose/ui/platform/TextToolbar$DefaultImpls { + public static fun showMenu (Landroidx/compose/ui/platform/TextToolbar;Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V public static synthetic fun showMenu$default (Landroidx/compose/ui/platform/TextToolbar;Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static synthetic fun showMenu$default (Landroidx/compose/ui/platform/TextToolbar;Landroidx/compose/ui/geometry/Rect;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V } public final class androidx/compose/ui/platform/TextToolbarStatus : java/lang/Enum { @@ -3509,11 +3679,6 @@ public abstract interface class androidx/compose/ui/platform/WindowInfo { public abstract fun isWindowFocused ()Z } -public final class androidx/compose/ui/platform/WindowInfo$DefaultImpls { - public static fun getContainerSize-YbymL2g (Landroidx/compose/ui/platform/WindowInfo;)J - public static fun getKeyboardModifiers-k7X9c1A (Landroidx/compose/ui/platform/WindowInfo;)I -} - public final class androidx/compose/ui/res/DesktopSvgResources_desktopKt { public static final fun loadSvgPainter (Ljava/io/InputStream;Landroidx/compose/ui/unit/Density;)Landroidx/compose/ui/graphics/painter/Painter; } @@ -3758,6 +3923,7 @@ public final class androidx/compose/ui/semantics/SemanticsActions { public final fun getGetScrollViewportLength ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getGetTextLayoutResult ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getInsertTextAtCursor ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; + public final fun getOnAutofillText ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getOnClick ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getOnImeAction ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getOnLongClick ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; @@ -3858,7 +4024,9 @@ public final class androidx/compose/ui/semantics/SemanticsProperties { public static final field INSTANCE Landroidx/compose/ui/semantics/SemanticsProperties; public final fun getCollectionInfo ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getCollectionItemInfo ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; + public final fun getContentDataType ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getContentDescription ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; + public final fun getContentType ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getDisabled ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getEditableText ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public final fun getError ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; @@ -3912,7 +4080,9 @@ public final class androidx/compose/ui/semantics/SemanticsPropertiesKt { public static synthetic fun expand$default (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V public static final fun getCollectionInfo (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Landroidx/compose/ui/semantics/CollectionInfo; public static final fun getCollectionItemInfo (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Landroidx/compose/ui/semantics/CollectionItemInfo; + public static final fun getContentDataType (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)I public static final fun getContentDescription (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Ljava/lang/String; + public static final fun getContentType (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Landroidx/compose/ui/autofill/ContentType; public static final fun getCustomActions (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Ljava/util/List; public static final fun getEditableText (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Landroidx/compose/ui/text/AnnotatedString; public static final fun getFocused (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Z @@ -3946,6 +4116,8 @@ public final class androidx/compose/ui/semantics/SemanticsPropertiesKt { public static final fun isEditable (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Z public static final fun isShowingTextSubstitution (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Z public static final fun isTraversalGroup (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Z + public static final fun onAutofillText (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun onAutofillText$default (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static final fun onClick (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public static synthetic fun onClick$default (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V public static final fun onImeAction-9UiTYpY (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;ILjava/lang/String;Lkotlin/jvm/functions/Function0;)V @@ -3977,7 +4149,9 @@ public final class androidx/compose/ui/semantics/SemanticsPropertiesKt { public static final fun setCollectionInfo (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Landroidx/compose/ui/semantics/CollectionInfo;)V public static final fun setCollectionItemInfo (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Landroidx/compose/ui/semantics/CollectionItemInfo;)V public static final fun setContainer (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Z)V + public static final fun setContentDataType-NTL_tik (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;I)V public static final fun setContentDescription (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Ljava/lang/String;)V + public static final fun setContentType (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Landroidx/compose/ui/autofill/ContentType;)V public static final fun setCustomActions (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Ljava/util/List;)V public static final fun setEditable (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Z)V public static final fun setEditableText (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Landroidx/compose/ui/text/AnnotatedString;)V @@ -4027,6 +4201,18 @@ public abstract interface class androidx/compose/ui/semantics/SemanticsPropertyR public abstract fun set (Landroidx/compose/ui/semantics/SemanticsPropertyKey;Ljava/lang/Object;)V } +public final class androidx/compose/ui/spatial/RectInfo { + public static final field $stable I + public final fun getHeight ()I + public final fun getPositionInRoot-nOcc-ac ()J + public final fun getPositionInScreen-nOcc-ac ()J + public final fun getPositionInWindow-nOcc-ac ()J + public final fun getRootRect ()Landroidx/compose/ui/unit/IntRect; + public final fun getScreenRect ()Landroidx/compose/ui/unit/IntRect; + public final fun getWidth ()I + public final fun getWindowRect ()Landroidx/compose/ui/unit/IntRect; +} + public final class androidx/compose/ui/state/ToggleableState : java/lang/Enum { public static final field Indeterminate Landroidx/compose/ui/state/ToggleableState; public static final field Off Landroidx/compose/ui/state/ToggleableState; diff --git a/compose/ui/ui/api/restricted_current.ignore b/compose/ui/ui/api/restricted_current.ignore index 8caaf36572d2f..97b7e7b00a5f9 100644 --- a/compose/ui/ui/api/restricted_current.ignore +++ b/compose/ui/ui/api/restricted_current.ignore @@ -7,6 +7,8 @@ AddedAbstractMethod: androidx.compose.ui.focus.FocusTargetModifierNode#setFocusa Added method androidx.compose.ui.focus.FocusTargetModifierNode.setFocusability(int) +BecameUnchecked: androidx.compose.ui.platform.AbstractComposeView#showLayoutBounds: + Removed property AbstractComposeView.showLayoutBounds from compatibility checked API surface BecameUnchecked: androidx.compose.ui.semantics.SemanticsProperties#InvisibleToUser: Removed property SemanticsProperties.InvisibleToUser from compatibility checked API surface BecameUnchecked: androidx.compose.ui.semantics.SemanticsProperties#getInvisibleToUser(): @@ -17,5 +19,11 @@ ChangedType: androidx.compose.ui.layout.MeasureResult#getAlignmentLines(): Method androidx.compose.ui.layout.MeasureResult.getAlignmentLines has changed return type from java.util.Map to java.util.Map -RemovedMethod: androidx.compose.ui.layout.LayoutCoordinates#transformToScreen(float[]): - Removed method androidx.compose.ui.layout.LayoutCoordinates.transformToScreen(float[]) +RemovedMethod: androidx.compose.ui.graphics.TransformOrigin#getPivotFractionX(): + Removed method androidx.compose.ui.graphics.TransformOrigin.getPivotFractionX() +RemovedMethod: androidx.compose.ui.graphics.TransformOrigin#getPivotFractionY(): + Removed method androidx.compose.ui.graphics.TransformOrigin.getPivotFractionY() +RemovedMethod: androidx.compose.ui.layout.ScaleFactor#getScaleX(): + Removed method androidx.compose.ui.layout.ScaleFactor.getScaleX() +RemovedMethod: androidx.compose.ui.layout.ScaleFactor#getScaleY(): + Removed method androidx.compose.ui.layout.ScaleFactor.getScaleY() diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt index a1bd75d4cc64e..24770bdf67cd0 100644 --- a/compose/ui/ui/api/restricted_current.txt +++ b/compose/ui/ui/api/restricted_current.txt @@ -10,14 +10,14 @@ package androidx.compose.ui { method public androidx.compose.ui.Alignment.Horizontal getRight(); method public androidx.compose.ui.Alignment getTopLeft(); method public androidx.compose.ui.Alignment getTopRight(); - property public final androidx.compose.ui.Alignment BottomLeft; - property public final androidx.compose.ui.Alignment BottomRight; - property public final androidx.compose.ui.Alignment CenterLeft; - property public final androidx.compose.ui.Alignment CenterRight; - property public final androidx.compose.ui.Alignment.Horizontal Left; - property public final androidx.compose.ui.Alignment.Horizontal Right; - property public final androidx.compose.ui.Alignment TopLeft; - property public final androidx.compose.ui.Alignment TopRight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomLeft; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomRight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterLeft; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterRight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal Left; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal Right; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopLeft; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopRight; field public static final androidx.compose.ui.AbsoluteAlignment INSTANCE; } @@ -42,21 +42,21 @@ package androidx.compose.ui { method public androidx.compose.ui.Alignment getTopCenter(); method public androidx.compose.ui.Alignment getTopEnd(); method public androidx.compose.ui.Alignment getTopStart(); - property public final androidx.compose.ui.Alignment.Vertical Bottom; - property public final androidx.compose.ui.Alignment BottomCenter; - property public final androidx.compose.ui.Alignment BottomEnd; - property public final androidx.compose.ui.Alignment BottomStart; - property public final androidx.compose.ui.Alignment Center; - property public final androidx.compose.ui.Alignment CenterEnd; - property public final androidx.compose.ui.Alignment.Horizontal CenterHorizontally; - property public final androidx.compose.ui.Alignment CenterStart; - property public final androidx.compose.ui.Alignment.Vertical CenterVertically; - property public final androidx.compose.ui.Alignment.Horizontal End; - property public final androidx.compose.ui.Alignment.Horizontal Start; - property public final androidx.compose.ui.Alignment.Vertical Top; - property public final androidx.compose.ui.Alignment TopCenter; - property public final androidx.compose.ui.Alignment TopEnd; - property public final androidx.compose.ui.Alignment TopStart; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Vertical Bottom; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomCenter; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomEnd; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment BottomStart; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment Center; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterEnd; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal CenterHorizontally; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment CenterStart; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Vertical CenterVertically; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal End; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Horizontal Start; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment.Vertical Top; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopCenter; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopEnd; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.Alignment TopStart; } @androidx.compose.runtime.Stable public static fun interface Alignment.Horizontal { @@ -132,8 +132,13 @@ package androidx.compose.ui { } @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public final class ComposeUiFlags { + property public final boolean NewNestedScrollFlingDispatchingEnabled; + property public final boolean isRectTrackingEnabled; + property public final boolean isSemanticAutofillEnabled; field public static final androidx.compose.ui.ComposeUiFlags INSTANCE; + field public static boolean NewNestedScrollFlingDispatchingEnabled; field public static boolean isRectTrackingEnabled; + field public static boolean isSemanticAutofillEnabled; } public final class ComposedModifierKt { @@ -216,6 +221,12 @@ package androidx.compose.ui.autofill { method public void requestAutofillForNode(androidx.compose.ui.autofill.AutofillNode autofillNode); } + public interface AutofillManager { + method public void cancel(); + method public void commit(); + method public void requestAutofillForActiveElement(); + } + public final class AutofillNode { ctor public AutofillNode(optional java.util.List autofillTypes, optional androidx.compose.ui.geometry.Rect? boundingBox, kotlin.jvm.functions.Function1? onFill); method public java.util.List getAutofillTypes(); @@ -276,6 +287,107 @@ package androidx.compose.ui.autofill { enum_constant public static final androidx.compose.ui.autofill.AutofillType Username; } + @kotlin.jvm.JvmInline public final value class ContentDataType { + ctor public ContentDataType(int dataType); + method public int getDataType(); + property public final int dataType; + field public static final androidx.compose.ui.autofill.ContentDataType.Companion Companion; + } + + public static final class ContentDataType.Companion { + method public int getDate(); + method public int getList(); + method public int getNone(); + method public int getText(); + method public int getToggle(); + property public final int Date; + property public final int List; + property public final int None; + property public final int Text; + property public final int Toggle; + } + + public final class ContentType { + ctor public ContentType(String contentHint); + method public operator androidx.compose.ui.autofill.ContentType plus(androidx.compose.ui.autofill.ContentType other); + field public static final androidx.compose.ui.autofill.ContentType.Companion Companion; + } + + public static final class ContentType.Companion { + method public androidx.compose.ui.autofill.ContentType getAddressAuxiliaryDetails(); + method public androidx.compose.ui.autofill.ContentType getAddressCountry(); + method public androidx.compose.ui.autofill.ContentType getAddressLocality(); + method public androidx.compose.ui.autofill.ContentType getAddressRegion(); + method public androidx.compose.ui.autofill.ContentType getAddressStreet(); + method public androidx.compose.ui.autofill.ContentType getBirthDateDay(); + method public androidx.compose.ui.autofill.ContentType getBirthDateFull(); + method public androidx.compose.ui.autofill.ContentType getBirthDateMonth(); + method public androidx.compose.ui.autofill.ContentType getBirthDateYear(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationDate(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationDay(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationMonth(); + method public androidx.compose.ui.autofill.ContentType getCreditCardExpirationYear(); + method public androidx.compose.ui.autofill.ContentType getCreditCardNumber(); + method public androidx.compose.ui.autofill.ContentType getCreditCardSecurityCode(); + method public androidx.compose.ui.autofill.ContentType getEmailAddress(); + method public androidx.compose.ui.autofill.ContentType getGender(); + method public androidx.compose.ui.autofill.ContentType getNewPassword(); + method public androidx.compose.ui.autofill.ContentType getNewUsername(); + method public androidx.compose.ui.autofill.ContentType getPassword(); + method public androidx.compose.ui.autofill.ContentType getPersonFirstName(); + method public androidx.compose.ui.autofill.ContentType getPersonFullName(); + method public androidx.compose.ui.autofill.ContentType getPersonLastName(); + method public androidx.compose.ui.autofill.ContentType getPersonMiddleInitial(); + method public androidx.compose.ui.autofill.ContentType getPersonMiddleName(); + method public androidx.compose.ui.autofill.ContentType getPersonNamePrefix(); + method public androidx.compose.ui.autofill.ContentType getPersonNameSuffix(); + method public androidx.compose.ui.autofill.ContentType getPhoneCountryCode(); + method public androidx.compose.ui.autofill.ContentType getPhoneNumber(); + method public androidx.compose.ui.autofill.ContentType getPhoneNumberDevice(); + method public androidx.compose.ui.autofill.ContentType getPhoneNumberNational(); + method public androidx.compose.ui.autofill.ContentType getPostalAddress(); + method public androidx.compose.ui.autofill.ContentType getPostalCode(); + method public androidx.compose.ui.autofill.ContentType getPostalCodeExtended(); + method public androidx.compose.ui.autofill.ContentType getSmsOtpCode(); + method public androidx.compose.ui.autofill.ContentType getUsername(); + property public final androidx.compose.ui.autofill.ContentType AddressAuxiliaryDetails; + property public final androidx.compose.ui.autofill.ContentType AddressCountry; + property public final androidx.compose.ui.autofill.ContentType AddressLocality; + property public final androidx.compose.ui.autofill.ContentType AddressRegion; + property public final androidx.compose.ui.autofill.ContentType AddressStreet; + property public final androidx.compose.ui.autofill.ContentType BirthDateDay; + property public final androidx.compose.ui.autofill.ContentType BirthDateFull; + property public final androidx.compose.ui.autofill.ContentType BirthDateMonth; + property public final androidx.compose.ui.autofill.ContentType BirthDateYear; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationDate; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationDay; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationMonth; + property public final androidx.compose.ui.autofill.ContentType CreditCardExpirationYear; + property public final androidx.compose.ui.autofill.ContentType CreditCardNumber; + property public final androidx.compose.ui.autofill.ContentType CreditCardSecurityCode; + property public final androidx.compose.ui.autofill.ContentType EmailAddress; + property public final androidx.compose.ui.autofill.ContentType Gender; + property public final androidx.compose.ui.autofill.ContentType NewPassword; + property public final androidx.compose.ui.autofill.ContentType NewUsername; + property public final androidx.compose.ui.autofill.ContentType Password; + property public final androidx.compose.ui.autofill.ContentType PersonFirstName; + property public final androidx.compose.ui.autofill.ContentType PersonFullName; + property public final androidx.compose.ui.autofill.ContentType PersonLastName; + property public final androidx.compose.ui.autofill.ContentType PersonMiddleInitial; + property public final androidx.compose.ui.autofill.ContentType PersonMiddleName; + property public final androidx.compose.ui.autofill.ContentType PersonNamePrefix; + property public final androidx.compose.ui.autofill.ContentType PersonNameSuffix; + property public final androidx.compose.ui.autofill.ContentType PhoneCountryCode; + property public final androidx.compose.ui.autofill.ContentType PhoneNumber; + property public final androidx.compose.ui.autofill.ContentType PhoneNumberDevice; + property public final androidx.compose.ui.autofill.ContentType PhoneNumberNational; + property public final androidx.compose.ui.autofill.ContentType PostalAddress; + property public final androidx.compose.ui.autofill.ContentType PostalCode; + property public final androidx.compose.ui.autofill.ContentType PostalCodeExtended; + property public final androidx.compose.ui.autofill.ContentType SmsOtpCode; + property public final androidx.compose.ui.autofill.ContentType Username; + } + } package androidx.compose.ui.contentcapture { @@ -475,6 +587,12 @@ package androidx.compose.ui.focus { property public final int Up; } + public sealed interface FocusEnterExitScope { + method public void cancelFocus(); + method public int getRequestedFocusDirection(); + property public abstract int requestedFocusDirection; + } + @Deprecated @kotlin.jvm.JvmDefaultWithCompatibility public interface FocusEventModifier extends androidx.compose.ui.Modifier.Element { method @Deprecated public void onFocusEvent(androidx.compose.ui.focus.FocusState focusState); } @@ -539,10 +657,12 @@ package androidx.compose.ui.focus { method public boolean getCanFocus(); method public default androidx.compose.ui.focus.FocusRequester getDown(); method public default androidx.compose.ui.focus.FocusRequester getEnd(); - method public default kotlin.jvm.functions.Function1 getEnter(); - method public default kotlin.jvm.functions.Function1 getExit(); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 getEnter(); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 getExit(); method public default androidx.compose.ui.focus.FocusRequester getLeft(); method public default androidx.compose.ui.focus.FocusRequester getNext(); + method public default kotlin.jvm.functions.Function1 getOnEnter(); + method public default kotlin.jvm.functions.Function1 getOnExit(); method public default androidx.compose.ui.focus.FocusRequester getPrevious(); method public default androidx.compose.ui.focus.FocusRequester getRight(); method public default androidx.compose.ui.focus.FocusRequester getStart(); @@ -550,10 +670,12 @@ package androidx.compose.ui.focus { method public void setCanFocus(boolean); method public default void setDown(androidx.compose.ui.focus.FocusRequester); method public default void setEnd(androidx.compose.ui.focus.FocusRequester); - method public default void setEnter(kotlin.jvm.functions.Function1); - method public default void setExit(kotlin.jvm.functions.Function1); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default void setEnter(kotlin.jvm.functions.Function1); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default void setExit(kotlin.jvm.functions.Function1); method public default void setLeft(androidx.compose.ui.focus.FocusRequester); method public default void setNext(androidx.compose.ui.focus.FocusRequester); + method public default void setOnEnter(kotlin.jvm.functions.Function1); + method public default void setOnExit(kotlin.jvm.functions.Function1); method public default void setPrevious(androidx.compose.ui.focus.FocusRequester); method public default void setRight(androidx.compose.ui.focus.FocusRequester); method public default void setStart(androidx.compose.ui.focus.FocusRequester); @@ -561,10 +683,12 @@ package androidx.compose.ui.focus { property public abstract boolean canFocus; property public default androidx.compose.ui.focus.FocusRequester down; property public default androidx.compose.ui.focus.FocusRequester end; - property public default kotlin.jvm.functions.Function1 enter; - property public default kotlin.jvm.functions.Function1 exit; + property @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 enter; + property @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public default kotlin.jvm.functions.Function1 exit; property public default androidx.compose.ui.focus.FocusRequester left; property public default androidx.compose.ui.focus.FocusRequester next; + property public default kotlin.jvm.functions.Function1 onEnter; + property public default kotlin.jvm.functions.Function1 onExit; property public default androidx.compose.ui.focus.FocusRequester previous; property public default androidx.compose.ui.focus.FocusRequester right; property public default androidx.compose.ui.focus.FocusRequester start; @@ -642,7 +766,8 @@ package androidx.compose.ui.focus { } public final class FocusRestorerKt { - method public static androidx.compose.ui.Modifier focusRestorer(androidx.compose.ui.Modifier, optional kotlin.jvm.functions.Function0? onRestoreFailed); + method public static androidx.compose.ui.Modifier focusRestorer(androidx.compose.ui.Modifier, optional androidx.compose.ui.focus.FocusRequester fallback); + method @Deprecated @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public static androidx.compose.ui.Modifier focusRestorer(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function0? onRestoreFailed); } public interface FocusState { @@ -767,6 +892,7 @@ package androidx.compose.ui.graphics { method public static androidx.compose.ui.graphics.GraphicsLayerScope GraphicsLayerScope(); method public static long getDefaultShadowColor(); method @androidx.compose.runtime.Composable @androidx.compose.runtime.ComposableOpenTarget(index=0xffffffff) public static androidx.compose.ui.graphics.layer.GraphicsLayer rememberGraphicsLayer(); + property public static final float DefaultCameraDistance; property public static final long DefaultShadowColor; field public static final float DefaultCameraDistance = 8.0f; } @@ -775,8 +901,7 @@ package androidx.compose.ui.graphics { method @androidx.compose.runtime.Stable public inline operator float component1(); method @androidx.compose.runtime.Stable public inline operator float component2(); method public long copy(optional float pivotFractionX, optional float pivotFractionY); - method public float getPivotFractionX(); - method public float getPivotFractionY(); + property @kotlin.PublishedApi internal final long packedValue; property public final float pivotFractionX; property public final float pivotFractionY; field public static final androidx.compose.ui.graphics.TransformOrigin.Companion Companion; @@ -895,10 +1020,24 @@ package androidx.compose.ui.graphics.vector { method public static long getDefaultTintColor(); method public static java.util.List getEmptyPath(); property public static final int DefaultFillType; + property public static final String DefaultGroupName; + property public static final String DefaultPathName; + property public static final float DefaultPivotX; + property public static final float DefaultPivotY; + property public static final float DefaultRotation; + property public static final float DefaultScaleX; + property public static final float DefaultScaleY; property public static final int DefaultStrokeLineCap; property public static final int DefaultStrokeLineJoin; + property public static final float DefaultStrokeLineMiter; + property public static final float DefaultStrokeLineWidth; property public static final int DefaultTintBlendMode; property public static final long DefaultTintColor; + property public static final float DefaultTranslationX; + property public static final float DefaultTranslationY; + property public static final float DefaultTrimPathEnd; + property public static final float DefaultTrimPathOffset; + property public static final float DefaultTrimPathStart; property public static final java.util.List EmptyPath; field public static final String DefaultGroupName = ""; field public static final String DefaultPathName = ""; @@ -930,6 +1069,7 @@ package androidx.compose.ui.graphics.vector { method @androidx.compose.runtime.Composable public static androidx.compose.ui.graphics.vector.VectorPainter rememberVectorPainter(androidx.compose.ui.graphics.vector.ImageVector image); method @androidx.compose.runtime.Composable @androidx.compose.runtime.ComposableOpenTarget(index=0xffffffff) public static androidx.compose.ui.graphics.vector.VectorPainter rememberVectorPainter(float defaultWidth, float defaultHeight, optional float viewportWidth, optional float viewportHeight, optional String name, optional long tintColor, optional int tintBlendMode, optional boolean autoMirror, kotlin.jvm.functions.Function2 content); method @Deprecated @androidx.compose.runtime.Composable @androidx.compose.runtime.ComposableOpenTarget(index=0xffffffff) public static androidx.compose.ui.graphics.vector.VectorPainter rememberVectorPainter(float defaultWidth, float defaultHeight, optional float viewportWidth, optional float viewportHeight, optional String name, optional long tintColor, optional int tintBlendMode, kotlin.jvm.functions.Function2 content); + property public static final String RootGroupName; field public static final String RootGroupName = "VectorRootGroup"; } @@ -1045,11 +1185,31 @@ package androidx.compose.ui.hapticfeedback { } public static final class HapticFeedbackType.Companion { + method public int getConfirm(); + method public int getContextClick(); + method public int getGestureEnd(); + method public int getGestureThresholdActivate(); method public int getLongPress(); + method public int getReject(); + method public int getSegmentFrequentTick(); + method public int getSegmentTick(); method public int getTextHandleMove(); + method public int getToggleOff(); + method public int getToggleOn(); + method public int getVirtualKey(); method public java.util.List values(); + property public final int Confirm; + property public final int ContextClick; + property public final int GestureEnd; + property public final int GestureThresholdActivate; property public final int LongPress; + property public final int Reject; + property public final int SegmentFrequentTick; + property public final int SegmentTick; property public final int TextHandleMove; + property public final int ToggleOff; + property public final int ToggleOn; + property public final int VirtualKey; } } @@ -1908,6 +2068,7 @@ package androidx.compose.ui.input.pointer { public final class PointerIconKt { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier pointerHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants); + method public static androidx.compose.ui.Modifier stylusHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants, optional androidx.compose.ui.node.DpTouchBoundsExpansion? touchBoundsExpansion); } public final class PointerIcon_androidKt { @@ -2117,6 +2278,7 @@ package androidx.compose.ui.layout { } public static final class AlignmentLine.Companion { + property public static final int Unspecified; } public final class AlignmentLineKt { @@ -2193,13 +2355,13 @@ package androidx.compose.ui.layout { method public androidx.compose.ui.layout.ContentScale getFit(); method public androidx.compose.ui.layout.ContentScale getInside(); method public androidx.compose.ui.layout.FixedScale getNone(); - property public final androidx.compose.ui.layout.ContentScale Crop; - property public final androidx.compose.ui.layout.ContentScale FillBounds; - property public final androidx.compose.ui.layout.ContentScale FillHeight; - property public final androidx.compose.ui.layout.ContentScale FillWidth; - property public final androidx.compose.ui.layout.ContentScale Fit; - property public final androidx.compose.ui.layout.ContentScale Inside; - property public final androidx.compose.ui.layout.FixedScale None; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale Crop; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale FillBounds; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale FillHeight; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale FillWidth; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale Fit; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.ContentScale Inside; + property @androidx.compose.runtime.Stable public final androidx.compose.ui.layout.FixedScale None; } @androidx.compose.runtime.Immutable public final class FixedScale implements androidx.compose.ui.layout.ContentScale { @@ -2258,6 +2420,7 @@ package androidx.compose.ui.layout { method public long localToWindow(long relativeToLocal); method public default long screenToLocal(long relativeToScreen); method public default void transformFrom(androidx.compose.ui.layout.LayoutCoordinates sourceCoordinates, float[] matrix); + method public default void transformToScreen(float[] matrix); method public long windowToLocal(long relativeToWindow); property public default boolean introducesMotionFrameOfReference; property public abstract boolean isAttached; @@ -2276,7 +2439,6 @@ package androidx.compose.ui.layout { method public static long positionInRoot(androidx.compose.ui.layout.LayoutCoordinates); method public static long positionInWindow(androidx.compose.ui.layout.LayoutCoordinates); method public static long positionOnScreen(androidx.compose.ui.layout.LayoutCoordinates); - method public static void transformToScreen(androidx.compose.ui.layout.LayoutCoordinates, float[] matrix); } public final class LayoutIdKt { @@ -2341,6 +2503,7 @@ package androidx.compose.ui.layout { method public androidx.compose.ui.layout.LayoutCoordinates getLookaheadScopeCoordinates(androidx.compose.ui.layout.Placeable.PlacementScope); method public default long localLookaheadPositionOf(androidx.compose.ui.layout.LayoutCoordinates, androidx.compose.ui.layout.LayoutCoordinates sourceCoordinates, optional long relativeToSource, optional boolean includeMotionFrameOfReference); method public androidx.compose.ui.layout.LayoutCoordinates toLookaheadCoordinates(androidx.compose.ui.layout.LayoutCoordinates); + property public androidx.compose.ui.layout.LayoutCoordinates lookaheadScopeCoordinates; } public final class LookaheadScopeKt { @@ -2428,6 +2591,11 @@ package androidx.compose.ui.layout { method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onPlaced(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1 onPlaced); } + public final class OnRectChangedModifierKt { + method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onRectChanged(androidx.compose.ui.Modifier, optional int throttleMs, optional int debounceMs, kotlin.jvm.functions.Function1 callback); + method public static kotlinx.coroutines.DisposableHandle registerOnRectChanged(androidx.compose.ui.node.DelegatableNode, int throttleMs, int debounceMs, kotlin.jvm.functions.Function1 callback); + } + @kotlin.jvm.JvmDefaultWithCompatibility public interface OnRemeasuredModifier extends androidx.compose.ui.Modifier.Element { method public void onRemeasured(long size); } @@ -2527,8 +2695,6 @@ package androidx.compose.ui.layout { method public long copy(optional float scaleX, optional float scaleY); method @androidx.compose.runtime.Stable public operator long div(float operand); method public long getPackedValue(); - method public inline float getScaleX(); - method public inline float getScaleY(); method @androidx.compose.runtime.Stable public operator long times(float operand); property public final long packedValue; property @androidx.compose.runtime.Stable public final inline float scaleX; @@ -2538,7 +2704,7 @@ package androidx.compose.ui.layout { public static final class ScaleFactor.Companion { method public long getUnspecified(); - property public final long Unspecified; + property @androidx.compose.runtime.Stable public final long Unspecified; } public final class ScaleFactorKt { @@ -2633,6 +2799,7 @@ package androidx.compose.ui.modifier { method public default T getCurrent(androidx.compose.ui.modifier.ModifierLocal); method public default androidx.compose.ui.modifier.ModifierLocalMap getProvidedValues(); method public default void provide(androidx.compose.ui.modifier.ModifierLocal key, T value); + property public T current; property public default androidx.compose.ui.modifier.ModifierLocalMap providedValues; } @@ -2659,6 +2826,7 @@ package androidx.compose.ui.modifier { public interface ModifierLocalReadScope { method public T getCurrent(androidx.compose.ui.modifier.ModifierLocal); + property public T current; } @androidx.compose.runtime.Stable public final class ProvidableModifierLocal extends androidx.compose.ui.modifier.ModifierLocal { @@ -2747,6 +2915,32 @@ package androidx.compose.ui.node { method protected final void undelegate(androidx.compose.ui.node.DelegatableNode instance); } + public final class DpTouchBoundsExpansion { + ctor public DpTouchBoundsExpansion(float start, float top, float end, float bottom, boolean isLayoutDirectionAware); + method public float component1-D9Ej5fM(); + method public float component2-D9Ej5fM(); + method public float component3-D9Ej5fM(); + method public float component4-D9Ej5fM(); + method public boolean component5(); + method public androidx.compose.ui.node.DpTouchBoundsExpansion copy-lDy3nrA(float start, float top, float end, float bottom, boolean isLayoutDirectionAware); + method public float getBottom(); + method public float getEnd(); + method public float getStart(); + method public float getTop(); + method public boolean isLayoutDirectionAware(); + method public long roundToTouchBoundsExpansion(androidx.compose.ui.unit.Density density); + property public final float bottom; + property public final float end; + property public final boolean isLayoutDirectionAware; + property public final float start; + property public final float top; + field public static final androidx.compose.ui.node.DpTouchBoundsExpansion.Companion Companion; + } + + public static final class DpTouchBoundsExpansion.Companion { + method public androidx.compose.ui.node.DpTouchBoundsExpansion Absolute(optional float left, optional float top, optional float right, optional float bottom); + } + public interface DrawModifierNode extends androidx.compose.ui.node.DelegatableNode { method public void draw(androidx.compose.ui.graphics.drawscope.ContentDrawScope); method public default void onMeasureResultChanged(); @@ -2819,11 +3013,13 @@ package androidx.compose.ui.node { } public interface PointerInputModifierNode extends androidx.compose.ui.node.DelegatableNode { + method public default long getTouchBoundsExpansion(); method public default boolean interceptOutOfBoundsChildEvents(); method public void onCancelPointerInput(); method public void onPointerEvent(androidx.compose.ui.input.pointer.PointerEvent pointerEvent, androidx.compose.ui.input.pointer.PointerEventPass pass, long bounds); method public default void onViewConfigurationChange(); method public default boolean sharePointerInputWithSiblings(); + property public default long touchBoundsExpansion; } public final class Ref { @@ -2858,6 +3054,26 @@ package androidx.compose.ui.node { method public static void invalidateSemantics(androidx.compose.ui.node.SemanticsModifierNode); } + @kotlin.jvm.JvmInline public final value class TouchBoundsExpansion { + property public final int bottom; + property public final int end; + property public final boolean isLayoutDirectionAware; + property public final int start; + property public final int top; + field public static final androidx.compose.ui.node.TouchBoundsExpansion.Companion Companion; + } + + public static final class TouchBoundsExpansion.Companion { + method public long Absolute(optional int left, optional int top, optional int right, optional int bottom); + method public long getNone(); + property public final long None; + } + + public final class TouchBoundsExpansionKt { + method public static androidx.compose.ui.node.DpTouchBoundsExpansion DpTouchBoundsExpansion(optional float start, optional float top, optional float end, optional float bottom); + method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom); + } + public interface TraversableNode extends androidx.compose.ui.node.DelegatableNode { method public Object getTraverseKey(); property public abstract Object traverseKey; @@ -2905,7 +3121,7 @@ package androidx.compose.ui.platform { method public final void setViewCompositionStrategy(androidx.compose.ui.platform.ViewCompositionStrategy strategy); property public final boolean hasComposition; property protected boolean shouldCreateCompositionOnAttachedToWindow; - property public final boolean showLayoutBounds; + property @SuppressCompatibility @androidx.compose.ui.InternalComposeUiApi public final boolean showLayoutBounds; } @kotlin.jvm.JvmDefaultWithCompatibility public interface AccessibilityManager { @@ -3019,6 +3235,7 @@ package androidx.compose.ui.platform { public final class CompositionLocalsKt { method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAccessibilityManager(); method @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofill(); + method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofillManager(); method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAutofillTree(); method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalClipboardManager(); method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalCursorBlinkEnabled(); @@ -3038,6 +3255,7 @@ package androidx.compose.ui.platform { method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalWindowInfo(); property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAccessibilityManager; property @SuppressCompatibility @androidx.compose.ui.ExperimentalComposeUiApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofill; + property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofillManager; property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAutofillTree; property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalClipboardManager; property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalCursorBlinkEnabled; @@ -3167,6 +3385,7 @@ package androidx.compose.ui.platform { method public androidx.compose.ui.platform.TextToolbarStatus getStatus(); method public void hide(); method public void showMenu(androidx.compose.ui.geometry.Rect rect, optional kotlin.jvm.functions.Function0? onCopyRequested, optional kotlin.jvm.functions.Function0? onPasteRequested, optional kotlin.jvm.functions.Function0? onCutRequested, optional kotlin.jvm.functions.Function0? onSelectAllRequested); + method public default void showMenu(androidx.compose.ui.geometry.Rect rect, optional kotlin.jvm.functions.Function0? onCopyRequested, optional kotlin.jvm.functions.Function0? onPasteRequested, optional kotlin.jvm.functions.Function0? onCutRequested, optional kotlin.jvm.functions.Function0? onSelectAllRequested, optional kotlin.jvm.functions.Function0? onAutofillRequested); property public abstract androidx.compose.ui.platform.TextToolbarStatus status; } @@ -3267,12 +3486,14 @@ package androidx.compose.ui.platform { public static final class ViewRootForTest.Companion { method public kotlin.jvm.functions.Function1? getOnViewCreatedCallback(); method public void setOnViewCreatedCallback(kotlin.jvm.functions.Function1?); - property public final kotlin.jvm.functions.Function1? onViewCreatedCallback; + property @VisibleForTesting public final kotlin.jvm.functions.Function1? onViewCreatedCallback; } @androidx.compose.runtime.Stable public interface WindowInfo { + method public default long getContainerSize(); method public default int getKeyboardModifiers(); method public boolean isWindowFocused(); + property public default long containerSize; property public abstract boolean isWindowFocused; property public default int keyboardModifiers; } @@ -3460,6 +3681,7 @@ package androidx.compose.ui.semantics { method public androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> getGetScrollViewportLength(); method public androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> getGetTextLayoutResult(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getInsertTextAtCursor(); + method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnAutofillText(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnClick(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnImeAction(); method public androidx.compose.ui.semantics.SemanticsPropertyKey>> getOnLongClick(); @@ -3488,6 +3710,7 @@ package androidx.compose.ui.semantics { property public final androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> GetScrollViewportLength; property public final androidx.compose.ui.semantics.SemanticsPropertyKey,java.lang.Boolean>>> GetTextLayoutResult; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> InsertTextAtCursor; + property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnAutofillText; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnClick; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnImeAction; property public final androidx.compose.ui.semantics.SemanticsPropertyKey>> OnLongClick; @@ -3591,7 +3814,9 @@ package androidx.compose.ui.semantics { public final class SemanticsProperties { method public androidx.compose.ui.semantics.SemanticsPropertyKey getCollectionInfo(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getCollectionItemInfo(); + method public androidx.compose.ui.semantics.SemanticsPropertyKey getContentDataType(); method public androidx.compose.ui.semantics.SemanticsPropertyKey> getContentDescription(); + method public androidx.compose.ui.semantics.SemanticsPropertyKey getContentType(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getDisabled(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getEditableText(); method public androidx.compose.ui.semantics.SemanticsPropertyKey getError(); @@ -3627,7 +3852,9 @@ package androidx.compose.ui.semantics { method public androidx.compose.ui.semantics.SemanticsPropertyKey getVerticalScrollAxisRange(); property public final androidx.compose.ui.semantics.SemanticsPropertyKey CollectionInfo; property public final androidx.compose.ui.semantics.SemanticsPropertyKey CollectionItemInfo; + property public final androidx.compose.ui.semantics.SemanticsPropertyKey ContentDataType; property public final androidx.compose.ui.semantics.SemanticsPropertyKey> ContentDescription; + property public final androidx.compose.ui.semantics.SemanticsPropertyKey ContentType; property public final androidx.compose.ui.semantics.SemanticsPropertyKey Disabled; property public final androidx.compose.ui.semantics.SemanticsPropertyKey EditableText; property public final androidx.compose.ui.semantics.SemanticsPropertyKey Error; @@ -3682,7 +3909,9 @@ package androidx.compose.ui.semantics { method public static void expand(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function0? action); method public static androidx.compose.ui.semantics.CollectionInfo getCollectionInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static androidx.compose.ui.semantics.CollectionItemInfo getCollectionItemInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method public static int getContentDataType(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static String getContentDescription(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method public static androidx.compose.ui.autofill.ContentType getContentType(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static java.util.List getCustomActions(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static androidx.compose.ui.text.AnnotatedString getEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static boolean getFocused(androidx.compose.ui.semantics.SemanticsPropertyReceiver); @@ -3713,6 +3942,7 @@ package androidx.compose.ui.semantics { method public static boolean isEditable(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static boolean isShowingTextSubstitution(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static boolean isTraversalGroup(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method public static void onAutofillText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function1? action); method public static void onClick(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function0? action); method public static void onImeAction(androidx.compose.ui.semantics.SemanticsPropertyReceiver, int imeActionType, optional String? label, kotlin.jvm.functions.Function0? action); method public static void onLongClick(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function0? action); @@ -3732,7 +3962,9 @@ package androidx.compose.ui.semantics { method public static void setCollectionInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.CollectionInfo); method public static void setCollectionItemInfo(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.CollectionItemInfo); method @Deprecated public static void setContainer(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); + method public static void setContentDataType(androidx.compose.ui.semantics.SemanticsPropertyReceiver, int); method public static void setContentDescription(androidx.compose.ui.semantics.SemanticsPropertyReceiver, String); + method public static void setContentType(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.autofill.ContentType); method public static void setCustomActions(androidx.compose.ui.semantics.SemanticsPropertyReceiver, java.util.List); method public static void setEditable(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); method public static void setEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.text.AnnotatedString); @@ -3760,11 +3992,38 @@ package androidx.compose.ui.semantics { method public static void setTraversalIndex(androidx.compose.ui.semantics.SemanticsPropertyReceiver, float); method public static void setVerticalScrollAxisRange(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.ScrollAxisRange); method public static void showTextSubstitution(androidx.compose.ui.semantics.SemanticsPropertyReceiver, optional String? label, kotlin.jvm.functions.Function1? action); + property public final androidx.compose.ui.semantics.CollectionInfo collectionInfo; + property public final androidx.compose.ui.semantics.CollectionItemInfo collectionItemInfo; + property public final int contentDataType; + property public final androidx.compose.ui.autofill.ContentType contentType; + property public final java.util.List customActions; + property public final androidx.compose.ui.text.AnnotatedString editableText; + property public final boolean focused; + property public final androidx.compose.ui.semantics.ScrollAxisRange horizontalScrollAxisRange; + property @Deprecated public final int imeAction; + property @Deprecated public final boolean isContainer; + property public final boolean isEditable; + property public final boolean isShowingTextSubstitution; + property public final boolean isTraversalGroup; + property public final int liveRegion; + property public final int maxTextLength; + property public final String paneTitle; + property public final androidx.compose.ui.semantics.ProgressBarRangeInfo progressBarRangeInfo; + property public final int role; + property public final boolean selected; + property public final String stateDescription; + property public final String testTag; + property public final long textSelectionRange; + property public final androidx.compose.ui.text.AnnotatedString textSubstitution; + property public final androidx.compose.ui.state.ToggleableState toggleableState; + property public final float traversalIndex; + property public final androidx.compose.ui.semantics.ScrollAxisRange verticalScrollAxisRange; } public final class SemanticsProperties_androidKt { method public static boolean getTestTagsAsResourceId(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method public static void setTestTagsAsResourceId(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); + property public final boolean testTagsAsResourceId; } public final class SemanticsPropertyKey { @@ -3782,6 +4041,29 @@ package androidx.compose.ui.semantics { } +package androidx.compose.ui.spatial { + + public final class RectInfo { + method public int getHeight(); + method public long getPositionInRoot(); + method public long getPositionInScreen(); + method public long getPositionInWindow(); + method public androidx.compose.ui.unit.IntRect getRootRect(); + method public androidx.compose.ui.unit.IntRect getScreenRect(); + method public int getWidth(); + method public androidx.compose.ui.unit.IntRect getWindowRect(); + property public final int height; + property public final long positionInRoot; + property public final long positionInScreen; + property public final long positionInWindow; + property public final androidx.compose.ui.unit.IntRect rootRect; + property public final androidx.compose.ui.unit.IntRect screenRect; + property public final int width; + property public final androidx.compose.ui.unit.IntRect windowRect; + } + +} + package androidx.compose.ui.state { public enum ToggleableState { diff --git a/compose/ui/ui/build.gradle b/compose/ui/ui/build.gradle index 863cd3bf9e443..83dae238a382b 100644 --- a/compose/ui/ui/build.gradle +++ b/compose/ui/ui/build.gradle @@ -160,7 +160,6 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { implementation(project(":collection:collection")) implementation(libs.kotlinStdlibCommon) implementation(libs.kotlinCoroutinesCore) - implementation(libs.atomicFu) // when updating the runtime version please also update the runtime-saveable version implementation(project(":compose:runtime:runtime")) @@ -205,6 +204,7 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { api(project(":compose:ui:ui-graphics")) api(project(":compose:ui:ui-text")) api(libs.skikoCommon) + implementation(libs.atomicFu) } } uikitMain { diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ScreenCoordinatesDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ScreenCoordinatesDemo.kt index c4ca3c9ff41a0..66186a8b07a7c 100644 --- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ScreenCoordinatesDemo.kt +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ScreenCoordinatesDemo.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionOnScreen -import androidx.compose.ui.layout.transformToScreen import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt index 760a348ae1485..39f577ef9d0b8 100644 --- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt @@ -16,6 +16,10 @@ package androidx.compose.ui.demos +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.O +import androidx.annotation.RequiresApi import androidx.compose.foundation.demos.text.SoftwareKeyboardControllerDemo import androidx.compose.integration.demos.common.ActivityDemo import androidx.compose.integration.demos.common.ComposableDemo @@ -27,7 +31,12 @@ import androidx.compose.ui.demos.accessibility.ScaffoldSampleDemo import androidx.compose.ui.demos.accessibility.ScaffoldSampleScrollDemo import androidx.compose.ui.demos.accessibility.ScrollingColumnDemo import androidx.compose.ui.demos.accessibility.SimpleRtlLayoutDemo +import androidx.compose.ui.demos.autofill.BTFResetCredentialsDemo +import androidx.compose.ui.demos.autofill.BasicSecureTextFieldAutofillDemo +import androidx.compose.ui.demos.autofill.BasicTextFieldAutofill import androidx.compose.ui.demos.autofill.ExplicitAutofillTypesDemo +import androidx.compose.ui.demos.autofill.LegacyTextFieldAutofillDemo +import androidx.compose.ui.demos.autofill.OutlinedTextFieldAutofillDemo import androidx.compose.ui.demos.focus.AdjacentScrollablesFocusDemo import androidx.compose.ui.demos.focus.CancelFocusDemo import androidx.compose.ui.demos.focus.CaptureFocusDemo @@ -265,6 +274,21 @@ private val ModifierDemos = ) ) +@RequiresApi(Build.VERSION_CODES.O) +private val AutofillDemos = + DemoCategory( + "Autofill", + listOf( + ComposableDemo("S: New login") { BTFResetCredentialsDemo() }, + ComposableDemo("S: BasicTextField Autofill") { BasicTextFieldAutofill() }, + ComposableDemo("S: BasicSecureTextField Autofill") { + BasicSecureTextFieldAutofillDemo() + }, + ComposableDemo("S: TextField Autofill") { LegacyTextFieldAutofillDemo() }, + ComposableDemo("S: OutlinedTextField Autofill") { OutlinedTextFieldAutofillDemo() } + ) + ) + val AccessibilityDemos = DemoCategory( "Accessibility", @@ -282,8 +306,9 @@ val AccessibilityDemos = val CoreDemos = DemoCategory( "Framework", - listOf( + listOfNotNull( ModifierDemos, + if (SDK_INT >= 26) AutofillDemos else null, ComposableDemo("Explicit autofill types") { ExplicitAutofillTypesDemo() }, FocusDemos, KeyInputDemos, diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt new file mode 100644 index 0000000000000..6e6cf38d07912 --- /dev/null +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt @@ -0,0 +1,195 @@ +/* + * 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. + */ + +package androidx.compose.ui.demos.autofill + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalAutofillManager +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController + +@RequiresApi(Build.VERSION_CODES.O) +@Preview +@Composable +fun AutofillNavigation() { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = "home") { + composable("home") { HomeScreen(navController) } + composable("login") { LoginScreen(navController) } + composable("submit") { SubmittedScreen(navController) } + } +} + +@Composable +fun HomeScreen(navController: NavController) { + Scaffold( + content = { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + // Navigation Button + Button( + onClick = { navController.navigate("login") }, + modifier = Modifier.align(Alignment.Start) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Go to Login" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Go to Login") + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = + "This is the Home Screen. From here, you can navigate to the Login Screen." + ) + } + } + ) +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun LoginScreen(navController: NavController) { + val autofillManager = LocalAutofillManager.current + + Scaffold( + content = { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + // Back Button ------------------------------------------------- + Button( + onClick = { + navController.navigate("home") + autofillManager?.cancel() + }, + modifier = Modifier.align(Alignment.Start) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to Home" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Back to Home") + } + + // Submit Button ------------------------------------------------- + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + navController.navigate("submit") + autofillManager?.commit() + }, + modifier = Modifier.align(Alignment.Start) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Submit" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Submit") + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = + """This is the Login Screen. You can go back to the Home Screen or + |enter submit your credentials below.""" + .trimMargin() + ) + + // Enter Credentials ------------------------------------------------- + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Enter your username and password below:") + Spacer(modifier = Modifier.height(8.dp)) + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + ContentType.Username + } + ) + + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + ContentType.Username + } + ) + } + } + ) +} + +@Composable +fun SubmittedScreen(navController: NavController) { + Scaffold( + content = { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + // Back Button + Button( + onClick = { navController.navigate("login") }, + modifier = Modifier.align(Alignment.Start) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to Login" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Back to Login") + } + + // Descriptive Text + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = + """This is the Success Screen. You can only go back to the + |Login Screen from here.""" + .trimMargin() + ) + } + } + ) +} diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/ExplicitAutofillTypesDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/ExplicitAutofillTypesDemo.kt index f75a6517127bc..739374e72a811 100644 --- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/ExplicitAutofillTypesDemo.kt +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/ExplicitAutofillTypesDemo.kt @@ -42,9 +42,6 @@ import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -// TODO(333102566): will add demos when semantic autofill goes live. For now, see -// AndroidSemanticAutofillTest.kt which can reference the internal properties. - @Composable @OptIn(ExperimentalComposeUiApi::class) fun ExplicitAutofillTypesDemo() { diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/TextFieldAutofillDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/TextFieldAutofillDemo.kt new file mode 100644 index 0000000000000..096d112a5aa71 --- /dev/null +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/TextFieldAutofillDemo.kt @@ -0,0 +1,241 @@ +/* + * 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. + */ + +package androidx.compose.ui.demos.autofill + +import android.annotation.SuppressLint +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicSecureTextField +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.Icon +import androidx.compose.material.IconToggleButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalAutofillManager +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@RequiresApi(Build.VERSION_CODES.O) +@SuppressLint("NullAnnotationGroup") +@Preview +@Composable +fun BTFResetCredentialsDemo() { + val autofillManager = LocalAutofillManager.current + + Column(modifier = Modifier.background(color = Color.Black)) { + Text(text = "Enter your new username and password below.", color = Color.White) + + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + contentType = ContentType.NewUsername + }, + textStyle = MaterialTheme.typography.body1.copy(color = Color.White), + cursorBrush = SolidColor(Color.White) + ) + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + contentType = ContentType.NewPassword + }, + textStyle = MaterialTheme.typography.body1.copy(color = Color.White), + cursorBrush = SolidColor(Color.White) + ) + + // Submit button + Button(onClick = { autofillManager?.commit() }) { Text("Reset credentials") } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@SuppressLint("NullAnnotationGroup") +@Preview +@Composable +fun BasicTextFieldAutofill() { + val autofillManager = LocalAutofillManager.current + + Column(modifier = Modifier.background(color = Color.Black)) { + Text(text = "Enter your username and password below.", color = Color.White) + + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + contentType = ContentType.Username + }, + textStyle = MaterialTheme.typography.body1.copy(color = Color.LightGray), + cursorBrush = SolidColor(Color.White) + ) + + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + contentType = ContentType.Password + }, + textStyle = MaterialTheme.typography.body1.copy(color = Color.LightGray), + cursorBrush = SolidColor(Color.White) + ) + + // Submit button + Button(onClick = { autofillManager?.commit() }) { Text("Submit credentials") } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@SuppressLint("NullAnnotationGroup") +@Preview +@Composable +fun BasicSecureTextFieldAutofillDemo() { + var visible by remember { mutableStateOf(false) } + + val autofillManager = LocalAutofillManager.current + + Column(modifier = Modifier.background(color = Color.Black)) { + Text(text = "Enter your username and password below.", color = Color.White) + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + contentType = ContentType.Username + }, + textStyle = MaterialTheme.typography.body1.copy(color = Color.LightGray), + cursorBrush = SolidColor(Color.White) + ) + // TODO(mnuzen): Check if `Password` ContentType should automatically + // be applied to a BasicSecureTextField. + BasicSecureTextField( + state = remember { TextFieldState() }, + textObfuscationMode = + if (visible) { + TextObfuscationMode.Visible + } else { + TextObfuscationMode.RevealLastTyped + }, + modifier = + Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics { + contentType = ContentType.Password + }, + textStyle = MaterialTheme.typography.body1.copy(color = Color.White), + cursorBrush = SolidColor(Color.White) + ) + + Checkbox(checked = visible, onCheckedChange = { visible = it }) + + IconToggleButton(checked = visible, onCheckedChange = { visible = it }) { + // TODO(MNUZEN): double check to make sure adding icon toggle does not break anything + if (visible) { + Icon(Icons.Default.Warning, "") + } else { + Icon(Icons.Default.Info, "") + } + } + + // Submit button + Button(onClick = { autofillManager?.commit() }) { Text("Submit credentials") } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@SuppressLint("NullAnnotationGroup") +@Preview +@Composable +fun LegacyTextFieldAutofillDemo() { + var usernameInput by remember { mutableStateOf("") } + var passwordInput by remember { mutableStateOf("") } + + val autofillManager = LocalAutofillManager.current + + Column { + // Username textfield + TextField( + value = usernameInput, + onValueChange = { usernameInput = it }, + label = { Text("Enter username here") }, + modifier = Modifier.semantics { contentType = ContentType.Username } + ) + + // Password textfield + TextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + label = { Text("Enter password here") }, + modifier = Modifier.semantics { contentType = ContentType.Password } + ) + + // Submit button + Button(onClick = { autofillManager?.commit() }) { Text("Submit credentials") } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@SuppressLint("NullAnnotationGroup") +@Preview +@Composable +fun OutlinedTextFieldAutofillDemo() { + var usernameInput by remember { mutableStateOf("") } + var passwordInput by remember { mutableStateOf("") } + + val autofillManager = LocalAutofillManager.current + + Column { + // Username textfield + OutlinedTextField( + value = usernameInput, + onValueChange = { usernameInput = it }, + label = { Text("Enter username here") }, + modifier = Modifier.semantics { contentType = ContentType.Username } + ) + + // Password textfield + OutlinedTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + label = { Text("Enter password here") }, + modifier = Modifier.semantics { contentType = ContentType.Password } + ) + + // Submit button + Button(onClick = { autofillManager?.commit() }) { Text("Submit credentials") } + } +} diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/ExplicitEnterExitWithCustomFocusEnterExitDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/ExplicitEnterExitWithCustomFocusEnterExitDemo.kt index 62ce8fb30ffd6..6bce9d2a3b348 100644 --- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/ExplicitEnterExitWithCustomFocusEnterExitDemo.kt +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/ExplicitEnterExitWithCustomFocusEnterExitDemo.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.focus.FocusDirection.Companion.Left import androidx.compose.ui.focus.FocusDirection.Companion.Right import androidx.compose.ui.focus.FocusDirection.Companion.Up import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusRequester.Companion.Default import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.InputMode @@ -69,19 +68,17 @@ fun ExplicitEnterExitWithCustomFocusEnterExitDemo() { Row( Modifier.focusRequester(row) .focusProperties { - enter = { - when (it) { - Down -> item1 - Enter -> item2 - Up -> item3 - else -> Default + onEnter = { + when (requestedFocusDirection) { + Down -> item1.requestFocus() + Enter -> item2.requestFocus() + Up -> item3.requestFocus() } } - exit = { - when (it) { - Left -> top - Right -> bottom - else -> Default + onExit = { + when (requestedFocusDirection) { + Left -> top.requestFocus() + Right -> bottom.requestFocus() } } } diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/FocusRestorationDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/FocusRestorationDemo.kt index ec99cf4af4647..1ec10d3d4bf9a 100644 --- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/FocusRestorationDemo.kt +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/FocusRestorationDemo.kt @@ -27,8 +27,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusRequester.Companion.Cancel -import androidx.compose.ui.focus.FocusRequester.Companion.Default import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer @@ -62,11 +60,8 @@ fun FocusRestorationDemo() { val focusRequester = remember { FocusRequester() } LazyRow( Modifier.focusRequester(focusRequester).focusProperties { - exit = { - focusRequester.saveFocusedChild() - Default - } - enter = { if (focusRequester.restoreFocusedChild()) Cancel else Default } + onExit = { focusRequester.saveFocusedChild() } + onEnter = { if (focusRequester.restoreFocusedChild()) cancelFocus() } } ) { item { Button("1") } diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/LazyListChildFocusDemos.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/LazyListChildFocusDemos.kt index ad4e1773c1bdd..568aee2667fd0 100644 --- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/LazyListChildFocusDemos.kt +++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/focus/LazyListChildFocusDemos.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusRequester.Companion.Default import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -79,7 +78,9 @@ fun LazyListChildFocusDemos() { stickyHeader { Text("Direct Focus to previously Focused Child") } item { var previouslyFocusedItem: FocusRequester? by remember { mutableStateOf(null) } - LazyRow(Modifier.focusProperties { enter = { previouslyFocusedItem ?: Default } }) { + LazyRow( + Modifier.focusProperties { onEnter = { previouslyFocusedItem?.requestFocus() } } + ) { items(10) { index -> val focusRequester = remember(index) { FocusRequester() } val pinnableContainer = LocalPinnableContainer.current diff --git a/compose/ui/ui/lint-baseline.xml b/compose/ui/ui/lint-baseline.xml index d19007ee3b9bb..bbfaca0158c7a 100644 --- a/compose/ui/ui/lint-baseline.xml +++ b/compose/ui/ui/lint-baseline.xml @@ -1,11 +1,11 @@ - + + errorLine1=" rule.runOnUiThread { Thread.sleep(sleepTime) }" + errorLine2=" ~~~~~"> @@ -19,6 +19,15 @@ file="src/androidMain/kotlin/androidx/compose/ui/platform/ComposeView.android.kt"/> + + + + - - - - - - - - + diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt index de4661909eac2..164d56284c771 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt @@ -3080,6 +3080,7 @@ class AndroidAccessibilityTest { } @Test + @FlakyTest(bugId = 364709885) @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) fun selectionEventBeforeTraverseEvent_whenTraverseTextField() { val text = "h" diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt index 939dfd04041e6..3de5c0b6f7acb 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt @@ -21,6 +21,7 @@ import android.view.View import android.view.ViewStructure import android.view.autofill.AutofillValue import androidx.autofill.HintConstants.AUTOFILL_HINT_PERSON_NAME +import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.LocalAutofill @@ -76,6 +77,8 @@ class AndroidAutoFillTest { @SdkSuppress(minSdkVersion = 26) @Test fun onProvideAutofillVirtualStructure_populatesViewStructure() { + if (isSemanticAutofillEnabled) return + // Arrange. val viewStructure: ViewStructure = FakeViewStructure() val autofillNode = @@ -110,6 +113,8 @@ class AndroidAutoFillTest { @SdkSuppress(minSdkVersion = 26) @Test fun autofill_triggersOnFill() { + if (isSemanticAutofillEnabled) return + // Arrange. val expectedValue = "PersonName" var autofilledValue = "" diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/SemanticAutofillManagerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt similarity index 72% rename from compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/SemanticAutofillManagerTest.kt rename to compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt index f6acab10baa2e..c098747977651 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/SemanticAutofillManagerTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt @@ -19,15 +19,19 @@ package androidx.compose.ui.autofill import android.os.Build import android.view.View import androidx.annotation.RequiresApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.platform.LocalAutofillManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDataType @@ -38,6 +42,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.test.TestActivity import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -54,7 +60,7 @@ import org.mockito.kotlin.verifyNoMoreInteractions @SdkSuppress(minSdkVersion = 26) @RequiresApi(Build.VERSION_CODES.O) @RunWith(AndroidJUnit4::class) -class SemanticAutofillManagerTest { +class AndroidAutofillManagerTest { @get:Rule val rule = createAndroidComposeRule() private lateinit var androidComposeView: AndroidComposeView @@ -222,6 +228,83 @@ class SemanticAutofillManagerTest { rule.runOnIdle { verify(autofillManagerMock).notifyViewVisibilityChanged(any(), any()) } } + @Test + @SmallTest + @SdkSuppress(minSdkVersion = 26) + fun autofillManager_notifyCommit() { + val forwardTag = "forward_button_tag" + var autofillManager: AutofillManager? + + rule.setContentWithAutofillEnabled { + autofillManager = LocalAutofillManager.current + Box( + modifier = + Modifier.clickable { autofillManager?.commit() } + .size(height, width) + .testTag(forwardTag) + ) + } + + rule.onNodeWithTag(forwardTag).performClick() + + rule.runOnIdle { verify(autofillManagerMock).commit() } + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = 26) + fun autofillManager_notifyCancel() { + val backTag = "back_button_tag" + var autofillManager: AutofillManager? + + rule.setContentWithAutofillEnabled { + autofillManager = LocalAutofillManager.current + Box( + modifier = + Modifier.clickable { autofillManager?.cancel() } + .size(height, width) + .testTag(backTag) + ) + } + rule.onNodeWithTag(backTag).performClick() + + rule.runOnIdle { verify(autofillManagerMock).cancel() } + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = 26) + fun autofillManager_requestAutofillAfterFocus() { + val contextMenuTag = "menu_tag" + var autofillManager: AutofillManager? + var hasFocus by mutableStateOf(false) + + rule.setContentWithAutofillEnabled { + autofillManager = LocalAutofillManager.current + Box( + modifier = + if (hasFocus) + Modifier.semantics { + contentType = ContentType.Username + contentDataType = ContentDataType.Text + focused = hasFocus + } + .clickable { autofillManager?.requestAutofillForActiveElement() } + .size(height, width) + .testTag(contextMenuTag) + else plainVisibleModifier(contextMenuTag) + ) + } + + // `requestAutofill` is always called after an element is focused + rule.runOnIdle { hasFocus = true } + rule.runOnIdle { verify(autofillManagerMock).notifyViewEntered(any(), any()) } + + // then `requestAutofill` is called on that same previously focused element + rule.onNodeWithTag(contextMenuTag).performClick() + rule.runOnIdle { verify(autofillManagerMock).requestAutofill(any(), any()) } + } + // ============================================================================================ // Helper functions // ============================================================================================ @@ -245,6 +328,7 @@ class SemanticAutofillManagerTest { .testTag(testTag) } + @OptIn(ExperimentalComposeUiApi::class) @RequiresApi(Build.VERSION_CODES.O) private fun ComposeContentTestRule.setContentWithAutofillEnabled( content: @Composable () -> Unit @@ -253,9 +337,9 @@ class SemanticAutofillManagerTest { setContent { androidComposeView = LocalView.current as AndroidComposeView - androidComposeView.semanticAutofill?._TEMP_AUTOFILL_FLAG = true - androidComposeView.semanticAutofill?.currentSemanticsNodesInvalidated = true - androidComposeView.semanticAutofill?.autofillManager = autofillManagerMock + androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true + androidComposeView._autofillManager?.autofillManager = autofillManagerMock + isSemanticAutofillEnabled = true composeView = LocalView.current diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidSemanticAutofillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/PerformAndroidAutofillManagerTest.kt similarity index 93% rename from compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidSemanticAutofillTest.kt rename to compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/PerformAndroidAutofillManagerTest.kt index 0a615a905fd92..d03a5f394ae65 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidSemanticAutofillTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/PerformAndroidAutofillManagerTest.kt @@ -26,7 +26,6 @@ import android.view.autofill.AutofillValue import android.view.inputmethod.EditorInfo import androidx.annotation.RequiresApi import androidx.autofill.HintConstants -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,13 +33,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.insert import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color import androidx.compose.ui.node.RootForTest import androidx.compose.ui.platform.AndroidComposeView import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat @@ -52,7 +51,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.maxTextLength -import androidx.compose.ui.semantics.onAutofillText import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.toggleableState @@ -80,7 +78,7 @@ import org.junit.runner.RunWith @RequiresApi(Build.VERSION_CODES.O) // TODO(MNUZEN): split into filling / saving etc. when more of Autofill goes live and more // data types are supported. -class AndroidPerformSemanticAutofillTest { +class PerformAndroidAutofillManagerTest { @get:Rule val rule = createAndroidComposeRule() private lateinit var androidComposeView: AndroidComposeView private lateinit var composeView: View @@ -830,13 +828,12 @@ class AndroidPerformSemanticAutofillTest { val viewStructure = FakeViewStructure() rule.setContentWithAutofillEnabled { - val usernameState = remember { TextFieldState() } - Column { BasicTextField( - state = usernameState, + state = remember { TextFieldState() }, modifier = - createTextFieldModifier(ContentType.Username, contentTag, usernameState) + Modifier.semantics { contentType = ContentType.Username } + .testTag(contentTag) ) } } @@ -884,7 +881,8 @@ class AndroidPerformSemanticAutofillTest { BasicTextField( state = passwordState, modifier = - createTextFieldModifier(ContentType.Password, contentTag, passwordState) + Modifier.semantics { contentType = ContentType.Password } + .testTag(contentTag) ) } } @@ -937,19 +935,18 @@ class AndroidPerformSemanticAutofillTest { val passwordTag = "password_tag" rule.setContentWithAutofillEnabled { - val usernameState = remember { TextFieldState() } - val passwordState = remember { TextFieldState() } - Column { BasicTextField( - state = usernameState, + state = remember { TextFieldState() }, modifier = - createTextFieldModifier(ContentType.Username, usernameTag, usernameState) + Modifier.semantics { contentType = ContentType.Username } + .testTag(usernameTag) ) BasicTextField( - state = passwordState, + state = remember { TextFieldState() }, modifier = - createTextFieldModifier(ContentType.Password, passwordTag, passwordState) + Modifier.semantics { contentType = ContentType.Password } + .testTag(passwordTag) ) } } @@ -983,20 +980,14 @@ class AndroidPerformSemanticAutofillTest { BasicTextField( state = creditCardInput, modifier = - createTextFieldModifier( - ContentType.Username, - creditCardTag, - creditCardInput - ) + Modifier.semantics { contentType = ContentType.CreditCardNumber } + .testTag(creditCardTag) ) BasicTextField( state = securityCodeInput, modifier = - createTextFieldModifier( - ContentType.Username, - securityCodeTag, - securityCodeInput - ) + Modifier.semantics { contentType = ContentType.CreditCardSecurityCode } + .testTag(securityCodeTag) ) } } @@ -1013,16 +1004,6 @@ class AndroidPerformSemanticAutofillTest { rule.onNodeWithTag(securityCodeTag).assertTextEquals(expectedSecurityCode) } - // TODO(b/333102566): Add BTF tests here after BTF and TF supports the new Autofill. - - // ============================================================================================ - // Tests to verify BasicTextField populating and filling. - // ============================================================================================ - - // ============================================================================================ - // Tests to verify TextField populating and filling. - // ============================================================================================ - // ============================================================================================ // Helper functions // ============================================================================================ @@ -1031,24 +1012,6 @@ class AndroidPerformSemanticAutofillTest { private fun Dp.dpToPx() = with(rule.density) { this@dpToPx.roundToPx() } - private fun createTextFieldModifier( - autofillHints: ContentType, - inputTag: String, - inputState: TextFieldState - ): Modifier { - return Modifier.border(1.dp, Color.LightGray) - .semantics { - contentType = autofillHints - contentDataType = ContentDataType.Text - onAutofillText { - inputState.edit { insert(0, it.text) } - true - } - } - .size(width, height) - .testTag(inputTag) - } - private inline fun FakeViewStructure(block: FakeViewStructure.() -> Unit): FakeViewStructure { return FakeViewStructure() .apply { @@ -1058,13 +1021,14 @@ class AndroidPerformSemanticAutofillTest { .apply(block) } + @OptIn(ExperimentalComposeUiApi::class) private fun ComposeContentTestRule.setContentWithAutofillEnabled( content: @Composable () -> Unit ) { setContent { androidComposeView = LocalView.current as AndroidComposeView - androidComposeView.semanticAutofill?._TEMP_AUTOFILL_FLAG = true - androidComposeView.semanticAutofill?.currentSemanticsNodesInvalidated = true + androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true + isSemanticAutofillEnabled = true composeView = LocalView.current LaunchedEffect(Unit) { diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt new file mode 100644 index 0000000000000..d00795c27ba17 --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt @@ -0,0 +1,157 @@ +/* + * 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. + */ + +package androidx.compose.ui.autofill + +import android.os.Build +import android.util.SparseArray +import android.view.View +import android.view.autofill.AutofillValue +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicSecureTextField +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.TestActivity +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.filters.SmallTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = 26) +@RequiresApi(Build.VERSION_CODES.O) +class TextFieldStateSemanticAutofillTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var androidComposeView: AndroidComposeView + private lateinit var composeView: View + + // ============================================================================================ + // Tests to verify BasicTextField populating and filling. + // ============================================================================================ + + @Test + @SmallTest + fun performAutofill_credentials_BTF() { + val expectedUsername = "test_username" + val expectedPassword = "test_password1111" + + val usernameTag = "username_tag" + val passwordTag = "password_tag" + + rule.setContentWithAutofillEnabled { + Column { + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.testTag(usernameTag).semantics { + contentType = ContentType.Username + } + ) + BasicTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.testTag(passwordTag).semantics { + contentType = ContentType.Password + } + ) + } + } + + val autofillValues = + SparseArray().apply { + append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername)) + append(passwordTag.semanticsId(), AutofillValue.forText(expectedPassword)) + } + + rule.runOnIdle { androidComposeView.autofill(autofillValues) } + + rule.onNodeWithTag(usernameTag).assertTextEquals(expectedUsername) + rule.onNodeWithTag(passwordTag).assertTextEquals(expectedPassword) + } + + @Test + @SmallTest + fun performAutofill_credentials_BSTF() { + val expectedUsername = "test_username" + val usernameTag = "username_tag" + + rule.setContentWithAutofillEnabled { + Column { + BasicSecureTextField( + state = remember { TextFieldState() }, + modifier = + Modifier.testTag(usernameTag).semantics { + contentType = ContentType.Username + } + ) + } + } + + val autofillValues = + SparseArray().apply { + append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername)) + } + + rule.runOnIdle { androidComposeView.autofill(autofillValues) } + + rule.onNodeWithTag(usernameTag).assertTextEquals(expectedUsername) + } + + // TODO(mnuzen): Mat3 dependencies are pinned, will add Autofill tests in Material3 module + + // ============================================================================================ + // Helper functions + // ============================================================================================ + + private fun String.semanticsId() = rule.onNodeWithTag(this).fetchSemanticsNode().id + + @OptIn(ExperimentalComposeUiApi::class) + private fun ComposeContentTestRule.setContentWithAutofillEnabled( + content: @Composable () -> Unit + ) { + setContent { + androidComposeView = LocalView.current as AndroidComposeView + androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true + isSemanticAutofillEnabled = true + + composeView = LocalView.current + LaunchedEffect(Unit) { + // Make sure the delay between batches of events is set to zero. + (composeView as RootForTest).setAccessibilityEventBatchIntervalMillis(0L) + } + content() + } + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt new file mode 100644 index 0000000000000..b6aa107d67dad --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt @@ -0,0 +1,256 @@ +/* + * 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. + */ + +package androidx.compose.ui.autofill + +import android.os.Build +import android.util.SparseArray +import android.view.View +import android.view.autofill.AutofillValue +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.AutofillHighlight +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.LocalAutofillHighlight +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.testutils.assertContainsColor +import androidx.compose.testutils.assertDoesNotContainColor +import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.TestActivity +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.filters.SmallTest +import junit.framework.TestCase.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = 26) +@RequiresApi(Build.VERSION_CODES.O) +class TextFieldsSemanticAutofillTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var androidComposeView: AndroidComposeView + private lateinit var composeView: View + private var autofillHighlight: AutofillHighlight? = null + + // ============================================================================================ + // Tests to verify legacy TextField populating and filling. + // ============================================================================================ + + @Test + @SmallTest + fun performAutofill_credentials_BTF() { + val expectedUsername = "test_username" + val expectedPassword = "test_password1111" + + val usernameTag = "username_tag" + val passwordTag = "password_tag" + + var usernameInput by mutableStateOf("") + var passwordInput by mutableStateOf("") + + rule.setContentWithAutofillEnabled { + Column { + BasicTextField( + value = usernameInput, + onValueChange = { usernameInput = it }, + modifier = + Modifier.testTag(usernameTag).semantics { + contentType = ContentType.Username + } + ) + BasicTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + modifier = + Modifier.testTag(passwordTag).semantics { + contentType = ContentType.Password + } + ) + } + } + + val autofillValues = + SparseArray().apply { + append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername)) + append(passwordTag.semanticsId(), AutofillValue.forText(expectedPassword)) + } + + rule.runOnIdle { androidComposeView.autofill(autofillValues) } + + rule.onNodeWithTag(usernameTag).assertTextEquals(expectedUsername) + rule.onNodeWithTag(passwordTag).assertTextEquals(expectedPassword) + } + + // ============================================================================================ + // Tests to verify TextField populating and filling. + // ============================================================================================ + + @Test + @SmallTest + fun performAutofill_credentials_legacyTF() { + val expectedUsername = "test_username" + val usernameTag = "username_tag" + var usernameInput by mutableStateOf("") + + rule.setContentWithAutofillEnabled { + Column { + TextField( + value = usernameInput, + onValueChange = { usernameInput = it }, + label = { Text("Enter username here") }, + modifier = + Modifier.testTag(usernameTag).semantics { + contentType = ContentType.Username + } + ) + } + } + + val autofillValues = + SparseArray().apply { + append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername)) + } + + rule.runOnIdle { androidComposeView.autofill(autofillValues) } + + assertEquals(usernameInput, expectedUsername) + } + + @Test + @SmallTest + fun performAutofill_credentials_outlinedTF() { + val expectedUsername = "test_username" + val usernameTag = "username_tag" + var usernameInput by mutableStateOf("") + + rule.setContentWithAutofillEnabled { + Column { + OutlinedTextField( + value = usernameInput, + onValueChange = { usernameInput = it }, + label = { Text("Enter username here") }, + modifier = + Modifier.testTag(usernameTag).semantics { + contentType = ContentType.Username + } + ) + } + } + + val autofillValues = + SparseArray().apply { + append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername)) + } + + rule.runOnIdle { androidComposeView.autofill(autofillValues) } + + assertEquals(usernameInput, expectedUsername) + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = 26) + fun performAutofill_customHighlight_legacyTF() { + val expectedUsername = "test_username" + val usernameTag = "username_tag" + var usernameInput by mutableStateOf("") + val customHighlightColor = Color.Red + + rule.setContentWithAutofillEnabled { + CompositionLocalProvider( + LocalAutofillHighlight provides AutofillHighlight(customHighlightColor) + ) { + Column { + TextField( + value = usernameInput, + onValueChange = { usernameInput = it }, + label = { Text("Enter username here") }, + modifier = + Modifier.testTag(usernameTag).semantics { + contentType = ContentType.Username + } + ) + } + } + } + + val autofillValues = + SparseArray().apply { + append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername)) + } + + // Custom autofill highlight color should not appear prior to autofill being performed + rule + .onNodeWithTag(usernameTag) + .captureToImage() + .assertDoesNotContainColor(customHighlightColor) + + rule.runOnIdle { androidComposeView.autofill(autofillValues) } + rule.waitForIdle() + + // Custom autofill highlight color should now appear + rule.onNodeWithTag(usernameTag).captureToImage().assertContainsColor(customHighlightColor) + } + + // ============================================================================================ + // Helper functions + // ============================================================================================ + + private fun String.semanticsId() = rule.onNodeWithTag(this).fetchSemanticsNode().id + + @OptIn(ExperimentalComposeUiApi::class) + private fun ComposeContentTestRule.setContentWithAutofillEnabled( + content: @Composable () -> Unit + ) { + setContent { + androidComposeView = LocalView.current as AndroidComposeView + androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true + isSemanticAutofillEnabled = true + + composeView = LocalView.current + autofillHighlight = LocalAutofillHighlight.current + LaunchedEffect(Unit) { + // Make sure the delay between batches of events is set to zero. + (composeView as RootForTest).setAccessibilityEventBatchIntervalMillis(0L) + } + content() + } + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/ClearFocusExitTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/ClearFocusExitTest.kt index 2e0506004103e..615bbfa785e94 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/ClearFocusExitTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/ClearFocusExitTest.kt @@ -18,7 +18,6 @@ package androidx.compose.ui.focus import androidx.compose.foundation.layout.Box import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester.Companion.Cancel import androidx.compose.ui.focus.FocusRequester.Companion.Default import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.test.junit4.createComposeRule @@ -46,12 +45,7 @@ class ClearFocusExitTest { focusManager = LocalFocusManager.current Box( Modifier.focusRequester(focusRequester) - .focusProperties { - exit = { - clearTriggered = true - Default - } - } + .focusProperties { onExit = { clearTriggered = true } } .onFocusChanged { focusState = it } .focusTarget() ) @@ -78,15 +72,7 @@ class ClearFocusExitTest { .onFocusChanged { focusState = it } .focusTarget() ) { - Box( - Modifier.focusProperties { - exit = { - clearTriggered = true - Default - } - } - .focusTarget() - ) + Box(Modifier.focusProperties { onExit = { clearTriggered = true } }.focusTarget()) } } rule.runOnIdle { focusRequester.requestFocus() } @@ -106,15 +92,7 @@ class ClearFocusExitTest { // Arrange. rule.setFocusableContent { focusManager = LocalFocusManager.current - Box( - Modifier.focusProperties { - exit = { - clearTriggered = true - Default - } - } - .focusTarget() - ) { + Box(Modifier.focusProperties { onExit = { clearTriggered = true } }.focusTarget()) { Box( Modifier.focusRequester(focusRequester) .onFocusChanged { focusState = it } @@ -145,7 +123,7 @@ class ClearFocusExitTest { focusManager = LocalFocusManager.current Box( Modifier.focusProperties { - exit = { + onExit = { clearTriggered = true Default } @@ -180,7 +158,7 @@ class ClearFocusExitTest { focusManager = LocalFocusManager.current Box( Modifier.focusRequester(focusRequester) - .focusProperties { exit = { Cancel } } + .focusProperties { onExit = { cancelFocus() } } .onFocusChanged { focusState = it } .focusTarget() ) @@ -204,7 +182,7 @@ class ClearFocusExitTest { .onFocusChanged { focusState = it } .focusTarget() ) { - Box(Modifier.focusProperties { exit = { Cancel } }) + Box(Modifier.focusProperties { onExit = { cancelFocus() } }) } } rule.runOnIdle { focusRequester.requestFocus() } @@ -221,7 +199,7 @@ class ClearFocusExitTest { // Arrange. rule.setFocusableContent { focusManager = LocalFocusManager.current - Box(Modifier.focusProperties { exit = { Cancel } }.focusTarget()) { + Box(Modifier.focusProperties { onExit = { cancelFocus() } }.focusTarget()) { Box( Modifier.focusRequester(focusRequester) .onFocusChanged { focusState = it } @@ -243,7 +221,7 @@ class ClearFocusExitTest { // Arrange. rule.setFocusableContent { focusManager = LocalFocusManager.current - Box(Modifier.focusProperties { exit = { Cancel } }.focusTarget()) { + Box(Modifier.focusProperties { onExit = { cancelFocus() } }.focusTarget()) { Box { Box( Modifier.focusRequester(focusRequester) @@ -268,7 +246,10 @@ class ClearFocusExitTest { val customDestination = FocusRequester() rule.setFocusableContent { focusManager = LocalFocusManager.current - Box(Modifier.focusProperties { exit = { customDestination } }.focusTarget()) { + Box( + Modifier.focusProperties { onExit = { customDestination.requestFocus() } } + .focusTarget() + ) { Box(Modifier.focusRequester(focusRequester).focusTarget()) Box( Modifier.focusRequester(customDestination) @@ -293,7 +274,7 @@ class ClearFocusExitTest { rule.setFocusableContent { focusManager = LocalFocusManager.current Box( - Modifier.focusProperties { exit = { customDestination } } + Modifier.focusProperties { onExit = { customDestination.requestFocus() } } .focusRequester(customDestination) .onFocusChanged { focusState = it } .focusTarget() @@ -317,7 +298,10 @@ class ClearFocusExitTest { rule.setFocusableContent { focusManager = LocalFocusManager.current Box { - Box(Modifier.focusProperties { exit = { customDestination } }.focusTarget()) { + Box( + Modifier.focusProperties { onExit = { customDestination.requestFocus() } } + .focusTarget() + ) { Box(Modifier.focusRequester(focusRequester).focusTarget()) } Box( diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/CustomFocusTraversalTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/CustomFocusTraversalTest.kt index 0d316a4e7e0f4..7fd1e00ad091f 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/CustomFocusTraversalTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/CustomFocusTraversalTest.kt @@ -427,9 +427,9 @@ class CustomFocusTraversalTest( Row( Modifier.focusRequester(parent) .focusProperties { - enter = { - directionThatTriggeredEnter = it - item2 + onEnter = { + directionThatTriggeredEnter = requestedFocusDirection + item2.requestFocus() } } .focusTarget() @@ -479,9 +479,9 @@ class CustomFocusTraversalTest( Modifier.focusRequester(item1) .onFocusChanged { item1Focused = it.isFocused } .focusProperties { - enter = { - directionThatTriggeredEnter = it - item3 + onEnter = { + directionThatTriggeredEnter = requestedFocusDirection + item3.requestFocus() } } .focusTarget() diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRequesterTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRequesterTest.kt index cc2d8adb32ce8..35fb72637adad 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRequesterTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRequesterTest.kt @@ -350,7 +350,7 @@ class FocusRequesterTest(private val modifierNodeVersion: Boolean) { Modifier.focusRequester(focusRequester) .focusProperties { canFocus = false - enter = { FocusRequester.Cancel } + onEnter = { cancelFocus() } } .focusTarget() ) { @@ -392,7 +392,7 @@ class FocusRequesterTest(private val modifierNodeVersion: Boolean) { Modifier.size(10.dp) .focusProperties { canFocus = false - enter = { FocusRequester.Cancel } + onEnter = { FocusRequester.Cancel } } .focusTarget() ) { @@ -493,7 +493,7 @@ class FocusRequesterTest(private val modifierNodeVersion: Boolean) { Column( modifier = Modifier.focusRequester(focusRequester) - .focusProperties { enter = { child2 } } + .focusProperties { onEnter = { child2.requestFocus() } } .focusProperties { canFocus = false } .focusTarget() ) { diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRestorerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRestorerTest.kt index 48c4a808a83c3..70dbcc9d3ab33 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRestorerTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusRestorerTest.kt @@ -196,7 +196,7 @@ class FocusRestorerTest { lateinit var child1State: FocusState lateinit var child2State: FocusState rule.setFocusableContent { - Box(Modifier.size(10.dp).focusRequester(parent).focusRestorer { child2 }.focusGroup()) { + Box(Modifier.size(10.dp).focusRequester(parent).focusRestorer(child2).focusGroup()) { key(1) { Box(Modifier.size(10.dp).onFocusChanged { child1State = it }.focusTarget()) } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusTransactionsTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusTransactionsTest.kt index 6569265ebe668..ca333f3277c1f 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusTransactionsTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusTransactionsTest.kt @@ -21,7 +21,6 @@ import android.view.View import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester.Companion.Cancel import androidx.compose.ui.focus.FocusStateImpl.ActiveParent import androidx.compose.ui.focus.FocusStateImpl.Inactive import androidx.compose.ui.input.InputMode.Companion.Keyboard @@ -186,7 +185,7 @@ class FocusTransactionsTest { val focusRequester = FocusRequester() rule.setFocusableContent { view = LocalView.current - Box(Modifier.focusProperties { enter = { Cancel } }.focusTarget()) { + Box(Modifier.focusProperties { onEnter = { cancelFocus() } }.focusTarget()) { Box(Modifier.focusRequester(focusRequester).focusTarget()) } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt index 8a51c94765962..73a1231ad7464 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt @@ -101,11 +101,7 @@ class FocusViewInteropTest { lateinit var view: View rule.setContent { view = LocalView.current - Box( - Modifier.size(10.dp) - .focusProperties { enter = { FocusRequester.Cancel } } - .focusGroup() - ) { + Box(Modifier.size(10.dp).focusProperties { onEnter = { cancelFocus() } }.focusGroup()) { Box(Modifier.size(10.dp).focusable()) } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterExitTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterExitTest.kt index ea8a290cd2ac5..5295b000ca132 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterExitTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterExitTest.kt @@ -88,11 +88,11 @@ class RequestFocusEnterExitTest { Box( Modifier.focusRequester(source) .focusProperties { - enter = { + onEnter = { child1.enter = counter++ Default } - exit = { + onExit = { child1.exit = counter++ Cancel } @@ -134,13 +134,10 @@ class RequestFocusEnterExitTest { Box(Modifier.focusTarget(grandParent)) { Box( Modifier.focusProperties { - enter = { - parent1.enter = counter++ - Default - } - exit = { + onEnter = { parent1.enter = counter++ } + onExit = { parent1.exit = counter++ - Cancel + cancelFocus() } } .focusTarget() @@ -180,11 +177,11 @@ class RequestFocusEnterExitTest { rule.setFocusableContent { Box( Modifier.focusProperties { - enter = { + onEnter = { grandParent.enter = counter++ Default } - exit = { + onExit = { grandParent.exit = counter++ Cancel } @@ -228,11 +225,11 @@ class RequestFocusEnterExitTest { rule.setFocusableContent { Box( Modifier.focusProperties { - enter = { + onEnter = { grandParent.enter = counter++ if (init) Default else Cancel } - exit = { + onExit = { grandParent.exit = counter++ Default } @@ -281,11 +278,11 @@ class RequestFocusEnterExitTest { } Box( Modifier.focusProperties { - enter = { + onEnter = { parent2.enter = counter++ Cancel } - exit = { + onExit = { parent2.exit = counter++ Default } @@ -331,11 +328,11 @@ class RequestFocusEnterExitTest { Box( Modifier.focusRequester(destination) .focusProperties { - enter = { + onEnter = { child4.enter = counter++ Cancel } - exit = { + onExit = { child4.exit = counter++ Default } @@ -377,11 +374,11 @@ class RequestFocusEnterExitTest { private fun Modifier.focusTarget(enterExitCounter: EnterExitCounter): Modifier = this.focusProperties { - enter = { + onEnter = { enterExitCounter.enter = counter++ Default } - exit = { + onExit = { enterExitCounter.exit = counter++ Default } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterTest.kt index 2afcd8d69621a..5238699ec769c 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusEnterTest.kt @@ -18,8 +18,6 @@ package androidx.compose.ui.focus import androidx.compose.foundation.layout.Box import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester.Companion.Cancel -import androidx.compose.ui.focus.FocusRequester.Companion.Default import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest @@ -43,12 +41,7 @@ class RequestFocusEnterTest { rule.setFocusableContent { Box( Modifier.focusRequester(focusRequester) - .focusProperties { - enter = { - enterTriggered = true - Default - } - } + .focusProperties { onEnter = { enterTriggered = true } } .onFocusChanged { focusState = it } .focusTarget() ) @@ -73,15 +66,7 @@ class RequestFocusEnterTest { .onFocusChanged { focusState = it } .focusTarget() ) { - Box( - Modifier.focusProperties { - enter = { - enterTriggered = true - Default - } - } - .focusTarget() - ) + Box(Modifier.focusProperties { onEnter = { enterTriggered = true } }.focusTarget()) } } @@ -99,15 +84,7 @@ class RequestFocusEnterTest { fun gainingFocus_triggersEnterForParent() { // Arrange. rule.setFocusableContent { - Box( - Modifier.focusProperties { - enter = { - enterTriggered = true - Default - } - } - .focusTarget() - ) { + Box(Modifier.focusProperties { onEnter = { enterTriggered = true } }.focusTarget()) { Box( Modifier.focusRequester(focusRequester) .onFocusChanged { focusState = it } @@ -130,15 +107,7 @@ class RequestFocusEnterTest { fun gainingFocus_triggersEnterForGrandparent() { // Arrange. rule.setFocusableContent { - Box( - Modifier.focusProperties { - enter = { - enterTriggered = true - Default - } - } - .focusTarget() - ) { + Box(Modifier.focusProperties { onEnter = { enterTriggered = true } }.focusTarget()) { Box { Box( Modifier.focusRequester(focusRequester) @@ -165,7 +134,7 @@ class RequestFocusEnterTest { rule.setFocusableContent { Box( Modifier.focusRequester(focusRequester) - .focusProperties { enter = { Cancel } } + .focusProperties { onEnter = { cancelFocus() } } .onFocusChanged { focusState = it } .focusTarget() ) @@ -187,7 +156,7 @@ class RequestFocusEnterTest { .onFocusChanged { focusState = it } .focusTarget() ) { - Box(Modifier.focusProperties { enter = { Cancel } }) + Box(Modifier.focusProperties { onEnter = { cancelFocus() } }) } } @@ -202,7 +171,7 @@ class RequestFocusEnterTest { fun cancellingFocusGain_usingEnterPropertyOnParent() { // Arrange. rule.setFocusableContent { - Box(Modifier.focusProperties { enter = { Cancel } }.focusTarget()) { + Box(Modifier.focusProperties { onEnter = { cancelFocus() } }.focusTarget()) { Box( Modifier.focusRequester(focusRequester) .onFocusChanged { focusState = it } @@ -222,7 +191,7 @@ class RequestFocusEnterTest { fun cancellingFocusGain_usingEnterPropertyOnGrandparent() { // Arrange. rule.setFocusableContent { - Box(Modifier.focusProperties { enter = { Cancel } }.focusTarget()) { + Box(Modifier.focusProperties { onEnter = { cancelFocus() } }.focusTarget()) { Box { Box( Modifier.focusRequester(focusRequester) @@ -246,7 +215,10 @@ class RequestFocusEnterTest { val customDestination = FocusRequester() rule.setFocusableContent { Box(Modifier.focusTarget()) { - Box(Modifier.focusProperties { enter = { customDestination } }.focusTarget()) { + Box( + Modifier.focusProperties { onEnter = { customDestination.requestFocus() } } + .focusTarget() + ) { Box(Modifier.focusRequester(focusRequester).focusTarget()) Box(Modifier.focusTarget()) } @@ -273,7 +245,10 @@ class RequestFocusEnterTest { val customDestination = FocusRequester() lateinit var destinationFocusState: FocusState rule.setFocusableContent { - Box(Modifier.focusProperties { enter = { customDestination } }.focusTarget()) { + Box( + Modifier.focusProperties { onEnter = { customDestination.requestFocus() } } + .focusTarget() + ) { Box( Modifier.focusRequester(focusRequester) .onFocusChanged { focusState = it } @@ -303,7 +278,10 @@ class RequestFocusEnterTest { val customDestination = FocusRequester() lateinit var destinationFocusState: FocusState rule.setFocusableContent { - Box(Modifier.focusProperties { enter = { customDestination } }.focusTarget()) { + Box( + Modifier.focusProperties { onEnter = { customDestination.requestFocus() } } + .focusTarget() + ) { Box( Modifier.focusRequester(focusRequester) .onFocusChanged { focusState = it } @@ -335,7 +313,7 @@ class RequestFocusEnterTest { rule.setFocusableContent { Box( Modifier.focusRequester(customDestination) - .focusProperties { enter = { customDestination } } + .focusProperties { onEnter = { customDestination.requestFocus() } } .onFocusChanged { destinationFocusState = it } .focusTarget() ) { @@ -367,9 +345,9 @@ class RequestFocusEnterTest { Box(Modifier.focusRequester(initialFocus).focusTarget()) Box( Modifier.focusProperties { - enter = { + onEnter = { enterCount++ - child2 + child2.requestFocus() } } .focusTarget() diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusExitTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusExitTest.kt index 0685df2f69fec..6170c7aef5c0f 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusExitTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/RequestFocusExitTest.kt @@ -41,9 +41,9 @@ class RequestFocusExitTest { Box(Modifier.focusRequester(destination).focusTarget()) Box( Modifier.focusProperties { - exit = { + onExit = { exitCount++ - child2 + child2.requestFocus() } } .focusTarget() diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalEnterTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalEnterTest.kt index 774c78a674f93..4da40231c56bb 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalEnterTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalEnterTest.kt @@ -210,7 +210,7 @@ class TwoDimensionalFocusTraversalEnterTest { val (child, grandchild) = List(2) { mutableStateOf(false) } rule.setContentForTest { FocusableBox(focusedItem, 0, 0, 30, 30, initialFocus) { - val customEnter = Modifier.focusProperties { enter = { FocusRequester.Cancel } } + val customEnter = Modifier.focusProperties { onEnter = { cancelFocus() } } FocusableBox(child, 10, 10, 10, 10, deactivated = true, modifier = customEnter) { FocusableBox(grandchild, 10, 10, 10, 10) } @@ -247,7 +247,8 @@ class TwoDimensionalFocusTraversalEnterTest { val grandchild2Requester = FocusRequester() rule.setContentForTest { FocusableBox(focusedItem, 0, 0, 30, 30, initialFocus) { - val customEnter = Modifier.focusProperties { enter = { grandchild2Requester } } + val customEnter = + Modifier.focusProperties { onEnter = { grandchild2Requester.requestFocus() } } FocusableBox(child, 10, 10, 10, 10, deactivated = true, modifier = customEnter) { FocusableBox(grandchild1, 10, 10, 10, 10) FocusableBox(grandchild2, 10, 10, 10, 10, grandchild2Requester) @@ -360,7 +361,7 @@ class TwoDimensionalFocusTraversalEnterTest { // Arrange. val children = List(6) { mutableStateOf(false) } val child3 = FocusRequester() - val customFocusEnter = Modifier.focusProperties { enter = { child3 } } + val customFocusEnter = Modifier.focusProperties { onEnter = { child3.requestFocus() } } rule.setContentForTest { FocusableBox(focusedItem, 0, 0, 70, 50, initialFocus, modifier = customFocusEnter) { FocusableBox(children[0], 10, 10, 10, 10) diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalExitTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalExitTest.kt index daa7d8d8de787..cf7c47538bfe6 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalExitTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalExitTest.kt @@ -208,7 +208,7 @@ class TwoDimensionalFocusTraversalExitTest { val (parent, grandparent) = List(2) { mutableStateOf(false) } rule.setContentForTest { FocusableBox(grandparent, 0, 0, 50, 50) { - val customExit = Modifier.focusProperties { exit = { FocusRequester.Cancel } } + val customExit = Modifier.focusProperties { onExit = { cancelFocus() } } FocusableBox(parent, 10, 10, 30, 30, deactivated = true) { FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus, modifier = customExit) } @@ -245,7 +245,7 @@ class TwoDimensionalFocusTraversalExitTest { val otherItem = FocusRequester() rule.setContentForTest { FocusableBox(grandparent, 0, 0, 50, 50) { - val customExit = Modifier.focusProperties { exit = { otherItem } } + val customExit = Modifier.focusProperties { onExit = { otherItem.requestFocus() } } FocusableBox(parent, 10, 10, 30, 30, deactivated = true) { FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus, modifier = customExit) } @@ -284,7 +284,7 @@ class TwoDimensionalFocusTraversalExitTest { val otherItem = FocusRequester() rule.setContentForTest { FocusableBox(grandparent, 0, 0, 50, 50) { - val customExit = Modifier.focusProperties { exit = { otherItem } } + val customExit = Modifier.focusProperties { onExit = { otherItem.requestFocus() } } FocusableBox(parent, 10, 10, 30, 30, deactivated = true) { FocusableBox(focusedItem, 10, 10, 10, 10, initialFocus, modifier = customExit) } @@ -438,9 +438,15 @@ class TwoDimensionalFocusTraversalExitTest { } /** - * ___________________________ | grandparent | | _____________________ | | | parent | | | | - * _______________ | | ____________ | | | focusedItem | | | | nextItem | | | |______________| | - * | |___________| | |____________________| | |__________________________| + * ___________________________ + * | grandparent | + * | _____________________ | + * | | parent | | + * | | _______________ | | ____________ + * | | | focusedItem | | | | nextItem | + * | | |______________| | | |___________| + * | |____________________| | + * |__________________________| */ @Test fun moveFocusRight_focusesOnSiblingOfGrandparent() { @@ -739,12 +745,15 @@ class TwoDimensionalFocusTraversalExitTest { } /** - * _________________________________________________________ | parent | | _______________ - * _______________ _______________ | | | focusedItem | | item1 | | item2 | | | |______________| - * |______________| |______________| | - * |________________________________________________________| _______________ _______________ - * _______________ | item3 | | item4 | | item5 | |______________| |______________| - * |______________| + * _________________________________________________________ + * | parent | + * | _______________ _______________ _______________ | + * | | focusedItem | | item1 | | item2 | | + * | |______________| |______________| |______________| | + * |________________________________________________________| + * _______________ _______________ _______________ + * | item3 | | item4 | | item5 | + * |______________| |______________| |______________| */ @Test fun moveFocusDown_fromBottommostItem_movesFocusOutsideParent() { @@ -779,12 +788,15 @@ class TwoDimensionalFocusTraversalExitTest { } /** - * _________________________________________________________ | parent | | _______________ - * _______________ _______________ | | | focusedItem | | item1 | | item2 | | | |______________| - * |______________| |______________| | - * |________________________________________________________| _______________ _______________ - * _______________ | item3 | | item4 | | item5 | |______________| |______________| - * |______________| + * _________________________________________________________ + * | parent | + * | _______________ _______________ _______________ | + * | | focusedItem | | item1 | | item2 | | + * | |______________| |______________| |______________| | + * |________________________________________________________| + * _______________ _______________ _______________ + * | item3 | | item4 | | item5 | + * |______________| |______________| |______________| */ @Test fun moveFocusDown_fromBottommostItem_movesFocusOutsideDeactivatedParent() { diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitEnterTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitEnterTest.kt index 54788337cd19f..2c1d1e7bae141 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitEnterTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitEnterTest.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.focus.FocusDirection.Companion.Down import androidx.compose.ui.focus.FocusDirection.Companion.Left import androidx.compose.ui.focus.FocusDirection.Companion.Right import androidx.compose.ui.focus.FocusDirection.Companion.Up -import androidx.compose.ui.focus.FocusRequester.Companion.Cancel import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule @@ -77,12 +76,12 @@ class TwoDimensionalFocusTraversalImplicitEnterTest(param: Param) { val (child1, child2, child3, child4) = FocusRequester.createRefs() val customFocusEnter = Modifier.focusProperties { - enter = { - when (it) { - Left -> child1 - Up -> child2 - Down -> child3 - Right -> child4 + onEnter = { + when (focusDirection) { + Left -> child1.requestFocus() + Up -> child2.requestFocus() + Down -> child3.requestFocus() + Right -> child4.requestFocus() else -> error("Invalid Direction") } } @@ -149,9 +148,9 @@ class TwoDimensionalFocusTraversalImplicitEnterTest(param: Param) { var directionSentToEnter: FocusDirection? = null val customFocusEnter = Modifier.focusProperties { - enter = { - directionSentToEnter = it - Cancel + onEnter = { + directionSentToEnter = focusDirection + cancelFocus() } } when (focusDirection) { @@ -189,28 +188,35 @@ class TwoDimensionalFocusTraversalImplicitEnterTest(param: Param) { } /** - * _________ | _________ - * | Up | | | Up | - * |________| | |________| - * ________________ | ________________ - * | parent | | | parent | - * | _________ | __________ | __________ | _________ | - * | | child0 | | | focused | | | focused | | | child0 | | - * | |________| | |_________| | |_________| | |________| | - * |_______________| | |_______________| - * _________ | _________ - * | Down | | | Down | - * |________| | |________| - * | - * moveFocus(Left) | moveFocus(Right) - * | - * - * ---------------------------------------------|-------------------------------------------- - * | __________ | | focused | | |_________| ________________ | ________________ | parent | | | - * parent | _________ | _________ | _________ | _________ | _________ | _________ | Left | | | - * child0 | | | Right | | | Left | | | child0 | | | Right | |________| | |________| | |________| - * | |________| | |________| | |________| |_______________| | |_______________| __________ | | - * focused | | |_________| | | moveFocus(Up) | moveFocus(Down) | + * _________ | _________ + * | Up | | | Up | + * |________| | |________| + * ________________ | ________________ + * | parent | | | parent | + * | _________ | __________ | __________ | _________ | + * | | child0 | | | focused | | | focused | | | child0 | | + * | |________| | |_________| | |_________| | |________| | + * |_______________| | |_______________| + * _________ | _________ + * | Down | | | Down | + * |________| | |________| + * | + * moveFocus(Left) | moveFocus(Right) + * ---------------------------------------------|-------------------------------------------- + * | __________ + * | | focused | + * | |_________| + * ________________ | ________________ + * | parent | | | parent | + * _________ | _________ | _________ | _________ | _________ | _________ + * | Left | | | child0 | | | Right | | | Left | | | child0 | | | Right | + * |________| | |________| | |________| | |________| | |________| | |________| + * |_______________| | |_______________| + * __________ | + * | focused | | + * |_________| | + * | + * moveFocus(Up) | moveFocus(Down) */ @Test fun moveFocusEnter_blockFocusChange_appropriateOtherItemIsFocused() { @@ -221,9 +227,9 @@ class TwoDimensionalFocusTraversalImplicitEnterTest(param: Param) { var directionSentToEnter: FocusDirection? = null val customFocusEnter = Modifier.focusProperties { - enter = { - directionSentToEnter = it - Cancel + onEnter = { + directionSentToEnter = focusDirection + cancelFocus() } } when (focusDirection) { @@ -309,15 +315,15 @@ class TwoDimensionalFocusTraversalImplicitEnterTest(param: Param) { } /** - * _________ - * | Up | - * |________| - * - * _________ _________ _________ | Left | | item | | Right | |________| |________| |________| - * - * _________ _________ - * | Down | | Other | - * |________| |________| + * _________ + * | Up | + * |________| + * _________ _________ _________ + * | Left | | item | | Right | + * |________| |________| |________| + * _________ _________ + * | Down | | Other | + * |________| |________| */ @Test fun focusOnItem_doesNotTriggerEnter() { @@ -325,7 +331,7 @@ class TwoDimensionalFocusTraversalImplicitEnterTest(param: Param) { val (up, down, left, right) = List(4) { mutableStateOf(false) } val (item, other) = List(2) { mutableStateOf(false) } var (upItem, downItem, leftItem, rightItem) = FocusRequester.createRefs() - val customFocusEnter = Modifier.focusProperties { enter = { Cancel } } + val customFocusEnter = Modifier.focusProperties { onEnter = { cancelFocus() } } when (focusDirection) { Left -> rightItem = initialFocus Right -> leftItem = initialFocus diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitExitTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitExitTest.kt index 787205acf4fc2..6e3c57ff17290 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitExitTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalImplicitExitTest.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.focus.FocusDirection.Companion.Down import androidx.compose.ui.focus.FocusDirection.Companion.Left import androidx.compose.ui.focus.FocusDirection.Companion.Right import androidx.compose.ui.focus.FocusDirection.Companion.Up -import androidx.compose.ui.focus.FocusRequester.Companion.Cancel import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule @@ -64,7 +63,7 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { val (left, right, top, bottom) = List(4) { mutableStateOf(false) } val otherItem = FocusRequester() rule.setContentForTest { - val customExit = Modifier.focusProperties { exit = { otherItem } } + val customExit = Modifier.focusProperties { onExit = { otherItem.requestFocus() } } FocusableBox(top, x = 20, y = 0, width = 10, height = 10, otherItem) FocusableBox(left, x = 0, y = 20, width = 10, height = 10, otherItem) FocusableBox(focusedItem, 20, 20, 10, 10, initialFocus, modifier = customExit) @@ -121,9 +120,9 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { FocusableBox(grandparent, 20, 20, 50, 50) { val customExit = Modifier.focusProperties { - exit = { - receivedFocusDirection = it - otherItem + onExit = { + receivedFocusDirection = focusDirection + otherItem.requestFocus() } } FocusableBox(parent, 10, 10, 30, 30, deactivated = true, modifier = customExit) { @@ -170,9 +169,9 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { FocusableBox(grandparent, 0, 0, 50, 50, otherItem) { val customExit = Modifier.focusProperties { - exit = { - receivedFocusDirection = it - otherItem + onExit = { + receivedFocusDirection = focusDirection + otherItem.requestFocus() } } FocusableBox(parent, 10, 10, 30, 30, deactivated = true, modifier = customExit) { @@ -213,7 +212,7 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { fun moveFocusExit_blockFocusChange() { // Arrange. val (up, down, left, right, parent) = List(5) { mutableStateOf(false) } - val customFocusExit = Modifier.focusProperties { exit = { Cancel } } + val customFocusExit = Modifier.focusProperties { onExit = { cancelFocus() } } rule.setContentForTest { FocusableBox(up, 30, 0, 10, 10) FocusableBox(left, 0, 30, 10, 10) @@ -258,7 +257,7 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { val (up, down, left, right, parent) = List(5) { mutableStateOf(false) } val (upItem, downItem, leftItem, rightItem) = FocusRequester.createRefs() - val customFocusExit = Modifier.focusProperties { exit = { Cancel } }.focusGroup() + val customFocusExit = Modifier.focusProperties { onExit = { cancelFocus() } }.focusGroup() rule.setContentForTest { FocusableBox(up, 30, 0, 10, 10, upItem) @@ -285,9 +284,18 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { } /** - * _________ _________ | dest | | Up | |________| |________| ________________ | parent | - * _________ | _________ | _________ | Left | | | source | | | Right | |________| | |________| | - * |________| |_______________| _________ | Down | |________| + * _________ _________ + * | dest | | Up | + * |________| |________| + * ________________ + * | parent | + * _________ | _________ | _________ + * | Left | | | source | | | Right | + * |________| | |________| | |________| + * |_______________| + * _________ + * | Down | + * |________| */ @Test fun moveFocusExit_redirectExit() { @@ -299,9 +307,9 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { val customFocusExit = Modifier.focusProperties { - exit = { + onExit = { initialFocus.requestFocus() - Cancel + cancelFocus() } } .focusGroup() @@ -355,7 +363,7 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { val (up, down, left, right) = List(4) { mutableStateOf(false) } val (upItem, downItem, leftItem, rightItem) = FocusRequester.createRefs() - val customFocusExit = Modifier.focusProperties { exit = { Cancel } }.focusGroup() + val customFocusExit = Modifier.focusProperties { onExit = { cancelFocus() } }.focusGroup() rule.setContentForTest { FocusableBox(up, 40, 0, 10, 10, upItem) @@ -384,9 +392,18 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { } /** - * _________ _________ | dest | | Up | |________| |________| _____________________ | - * grandparent+parent | _________ | _________ | _________ | Left | | | source | | | Right | - * |________| | |________| | |________| |____________________| _________ | Down | |________| + * _________ _________ + * | dest | | Up | + * |________| |________| + * _____________________ + * | grandparent+parent | + * _________ | _________ | _________ + * | Left | | | source | | | Right | + * |________| | |________| | |________| + * |____________________| + * _________ + * | Down | + * |________| */ @Test fun moveFocusExit_multipleParents_redirectExit() { @@ -399,9 +416,9 @@ class TwoDimensionalFocusTraversalImplicitExitTest(param: Param) { val customFocusExit = Modifier.focusGroup() .focusProperties { - exit = { + onExit = { initialFocus.requestFocus() - Cancel + cancelFocus() } } .focusGroup() diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalThreeItemsTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalThreeItemsTest.kt index a18a73d0f8a4f..14545cdba24d9 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalThreeItemsTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusTraversalThreeItemsTest.kt @@ -56,19 +56,31 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { } /** - * __________ __________ * __________ | Next | | Closer | * ^ | Next | | Item | | Item | * | | - * Item | |_________| |_________| * Direction |_________| ____________ * of Search | focused | * - * | | Item | * | |___________| * ____________ - * * | focused | __________ - * * | Item | | Closer | <---- Direction of Search --- * |___________| | Item | - * * |_________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ _________ * __________ | Closer | | Next | * | Closer | | Item | | Item | * - * ____________ | Item | |_________| |________| * | focused | |_________| ____________ * | Item - * | | focused | * |___________| | Item | * |___________| * | _________ - * * Direction | Next | ---- Direction of Search ---> * of Search | Item | - * * | |________| - * * V + * __________ __________ * __________ + * | Next | | Closer | * ^ | Next | + * | Item | | Item | * | | Item | + * |_________| |_________| * Direction |_________| + * ____________ * of Search + * | focused | * | + * | Item | * | + * |___________| * ____________ + * * | focused | __________ + * * | Item | | Closer | + * <---- Direction of Search --- * |___________| | Item | + * * |_________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ _________ * __________ + * | Closer | | Next | * | Closer | + * | Item | | Item | * ____________ | Item | + * |_________| |________| * | focused | |_________| + * ____________ * | Item | + * | focused | * |___________| + * | Item | * + * |___________| * | _________ + * * Direction | Next | + * ---- Direction of Search ---> * of Search | Item | + * * | |________| + * * V */ @MediumTest @Test @@ -125,13 +137,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * |________| |_________| * | Closer | | Item | * <---- Direction of Search --- * | Item | |___________| * * |_________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * __________ | focused | * | Closer | | Item | * | Item | ____________ - * |___________| * |_________| | focused | __________ _________ * | Item | | Closer | | Next | * - * |___________| | Item | | Item | * |_________| |________| * _________ | - * * | Next | Direction ---- Direction of Search ---> * | Item | of Search - * * |________| | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * __________ + * | focused | * | Closer | + * | Item | * | Item | ____________ + * |___________| * |_________| | focused | + * __________ _________ * | Item | + * | Closer | | Next | * |___________| + * | Item | | Item | * + * |_________| |________| * _________ | + * * | Next | Direction + * ---- Direction of Search ---> * | Item | of Search + * * |________| | + * * V */ @LargeTest @Test @@ -176,18 +194,34 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { } /** - * _________ * _________ | Next | * | Next | ^ | Item | * | Item | | |________| * |________| - * Direction ____________ * of Search | focused | * | | Item | * | |___________| * ____________ - * __________ * | focused | | Closer | * | Item | __________ | Item | * |___________| | Closer | - * |_________| * | Item | <---- Direction of Search --- * |_________| - * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * __________ | Closer | * | Closer | | Item | * ____________ | Item | - * |_________| * | focused | |_________| ____________ * | Item | | focused | * |___________| | - * Item | * | |___________| * _________ Direction _________ * | Next | of Search | Next | * | - * Item | | | Item | * |________| | |________| * V ---- Direction of Search ---> * - * * - * * + * _________ * _________ + * | Next | * | Next | ^ + * | Item | * | Item | | + * |________| * |________| Direction + * ____________ * of Search + * | focused | * | + * | Item | * | + * |___________| * ____________ + * __________ * | focused | + * | Closer | * | Item | __________ + * | Item | * |___________| | Closer | + * |_________| * | Item | + * <---- Direction of Search --- * |_________| + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * __________ + * | Closer | * | Closer | + * | Item | * ____________ | Item | + * |_________| * | focused | |_________| + * ____________ * | Item | + * | focused | * |___________| + * | Item | * | + * |___________| * _________ Direction + * _________ * | Next | of Search + * | Next | * | Item | | + * | Item | * |________| | + * |________| * V + * ---- Direction of Search ---> * */ @LargeTest @Test @@ -246,13 +280,22 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * |________| * | Item | * <---- Direction of Search --- * |_________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _________ * __________ | Next | * | Closer | | Item | * | Item | ____________ |________| * - * |_________| | focused | ____________ * | Item | | focused | * |___________| | Item | * | - * |___________| * Direction _________ __________ * of Search | Next | | Closer | * | | Item | | - * Item | * | |________| |_________| * V ---- Direction of Search ---> * - * * - * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _________ * __________ + * | Next | * | Closer | + * | Item | * | Item | ____________ + * |________| * |_________| | focused | + * ____________ * | Item | + * | focused | * |___________| + * | Item | * | + * |___________| * Direction _________ + * __________ * of Search | Next | + * | Closer | * | | Item | + * | Item | * | |________| + * |_________| * V + * ---- Direction of Search ---> * + * * + * * */ @LargeTest @Test @@ -309,14 +352,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * |___________| | Closer | ____________ __________ * | Item | | focused | | Item - * | * |_________| | Item | | in beam | * |___________| |_________| * ____________ - * * | Item | | ---- Direction of Search ---> * | in beam | Direction - * * |___________| of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * |___________| | Closer | + * ____________ __________ * | Item | + * | focused | | Item | * |_________| + * | Item | | in beam | * + * |___________| |_________| * ____________ + * * | Item | | + * ---- Direction of Search ---> * | in beam | Direction + * * |___________| of Search + * * | + * * V */ @MediumTest @Test @@ -373,14 +422,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * |___________| | Closer | _______________ * | Item | ____________ | Item in Beam - * | * |_________| | focused | |______________| * | Item | * _________ |___________| * | Item | - * | - * * | in beam| Direction ---- Direction of Search ---> * |________| of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * |___________| | Closer | + * _______________ * | Item | + * ____________ | Item in Beam | * |_________| + * | focused | |______________| * + * | Item | * _________ + * |___________| * | Item | | + * * | in beam| Direction + * ---- Direction of Search ---> * |________| of Search + * * | + * * V */ @LargeTest @Test @@ -437,14 +492,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * |___________| | Closer | ____________ _______________ * | Item | | focused | | - * Item in Beam | * |_________| | Item | |______________| * |___________| * _________ - * * | Item | | ---- Direction of Search ---> * | in beam| Direction - * * |________| of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * |___________| | Closer | + * ____________ _______________ * | Item | + * | focused | | Item in Beam | * |_________| + * | Item | |______________| * + * |___________| * _________ + * * | Item | | + * ---- Direction of Search ---> * | in beam| Direction + * * |________| of Search + * * | + * * V */ @LargeTest @Test @@ -501,14 +562,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * |___________| | Closer | ____________ * | Item | | | _______________ * - * |_________| | focused | | Item in Beam | * | Item | |______________| * _______ - * |___________| * | Item | | - * * | in | Direction - * * | Beam | of Search ---- Direction of Search ---> * |______| | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * |___________| | Closer | + * ____________ * | Item | + * | | _______________ * |_________| + * | focused | | Item in Beam | * + * | Item | |______________| * _______ + * |___________| * | Item | | + * * | in | Direction + * * | Beam | of Search + * ---- Direction of Search ---> * |______| | + * * V */ @LargeTest @Test @@ -565,14 +632,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | |_________| * <---- Direction of Search --- * |_____________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * _____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * |____________| | Closer | ____________ * | Item | | focused | _______________ * - * |_________| | Item | | Item in Beam | * |___________| |______________| * _________ - * * | | | ---- Direction of Search ---> * | Item | Direction - * * | in beam| of Search - * * | | | - * * |________| V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * _____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * |____________| | Closer | + * ____________ * | Item | + * | focused | _______________ * |_________| + * | Item | | Item in Beam | * + * |___________| |______________| * _________ + * * | | | + * ---- Direction of Search ---> * | Item | Direction + * * | in beam| of Search + * * | | | + * * |________| V */ @LargeTest @Test @@ -629,13 +702,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * |______________| * | focused | |_________| * * | Item | * <---- Direction of Search --- * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * |___________| | Closer | ____________ * | Item | | focused | * |_________| | - * Item | _______________ * |___________| | | * _________ | Item in Beam | * | Item | | - * |______________| * | in beam | Direction - * * |________ | of Search ---- Direction of Search ---> * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * |___________| | Closer | + * ____________ * | Item | + * | focused | * |_________| + * | Item | _______________ * + * |___________| | | * _________ + * | Item in Beam | * | Item | | + * |______________| * | in beam | Direction + * * |________ | of Search + * ---- Direction of Search ---> * | + * * V */ @LargeTest @Test @@ -691,15 +771,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item |__________ - * _______|_________| __________ * |___________| Closer | | focused | | Item | * | Item | | Item - * | | in beam | * |_________| |___________| |_________| * - * * ____________ ---- Direction of Search ---> * | Item | | - * * | in beam | Direction - * * |___________| of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item |__________ + * _______|_________| __________ * |___________| Closer | + * | focused | | Item | * | Item | + * | Item | | in beam | * |_________| + * |___________| |_________| * + * * ____________ + * ---- Direction of Search ---> * | Item | | + * * | in beam | Direction + * * |___________| of Search + * * | + * * V */ @LargeTest @Test @@ -756,15 +841,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | _______________ * | Item - * |__________ _______|_________| | Item in Beam | * |___________| Closer | | focused | - * |______________| * | Item | | Item | * |_________| |___________| * - * * _________ - * * | Item | | ---- Direction of Search ---> * | in beam| Direction - * * |________| of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | _______________ * | Item |__________ + * _______|_________| | Item in Beam | * |___________| Closer | + * | focused | |______________| * | Item | + * | Item | * |_________| + * |___________| * + * * _________ + * * | Item | | + * ---- Direction of Search ---> * | in beam| Direction + * * |________| of Search + * * | + * * V */ @LargeTest @Test @@ -821,15 +911,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * | Item |_________| * * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item |__________ - * _______|_________| _______________ * |___________| Closer | | focused | | Item in Beam | * | - * Item | | Item | |______________| * |_________| |___________| * - * * _________ - * * | | | ---- Direction of Search ---> * | Item | Direction - * * | in beam| of Search - * * |________| | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item |__________ + * _______|_________| _______________ * |___________| Closer | + * | focused | | Item in Beam | * | Item | + * | Item | |______________| * |_________| + * |___________| * + * * _________ + * * | | | + * ---- Direction of Search ---> * | Item | Direction + * * | in beam| of Search + * * |________| | + * * V */ @LargeTest @Test @@ -886,14 +981,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * | Item |_________| * * |____________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * _____________ | Closer | * | focused | | Item | * | Item |__________ - * _______|_________| * |____________| Closer | | | _______________ * | Item | | focused | | - * Item in Beam | * |_________| | Item | |______________| * |___________| * _______ - * * | Item | | - * * | in | Direction ---- Direction of Search ---> * | Beam | of Search - * * |______| | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * _____________ + * | Closer | * | focused | + * | Item | * | Item |__________ + * _______|_________| * |____________| Closer | + * | | _______________ * | Item | + * | focused | | Item in Beam | * |_________| + * | Item | |______________| * + * |___________| * _______ + * * | Item | | + * * | in | Direction + * ---- Direction of Search ---> * | Beam | of Search + * * |______| | + * * V */ @LargeTest @Test @@ -950,14 +1051,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item |_________| * <---- Direction of Search --- * |____________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * _____________ __________ * | focused | | Closer | * | Item |__________ | Item | * - * |____________| Closer | _______|_________| * | Item | | focused | _______________ * - * |_________| | Item | | Item in Beam | * |___________| |______________| * _________ - * * | | | ---- Direction of Search ---> * | Item | Direction - * * | in beam| of Search - * * |________| | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * _____________ + * __________ * | focused | + * | Closer | * | Item |__________ + * | Item | * |____________| Closer | + * _______|_________| * | Item | + * | focused | _______________ * |_________| + * | Item | | Item in Beam | * + * |___________| |______________| * _________ + * * | | | + * ---- Direction of Search ---> * | Item | Direction + * * | in beam| of Search + * * |________| | + * * V */ @LargeTest @Test @@ -1013,14 +1120,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | Item | * <---- Direction of Search --- * | Item |_________| * * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item |__________ - * _______|_________| * |___________| Closer | | focused | * | Item | | Item | _______________ * - * |_________| |___________| | Item in Beam | * |______________| * _________ - * * | Item | | ---- Direction of Search ---> * | in beam| Direction - * * |________| of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item |__________ + * _______|_________| * |___________| Closer | + * | focused | * | Item | + * | Item | _______________ * |_________| + * |___________| | Item in Beam | * + * |______________| * _________ + * * | Item | | + * ---- Direction of Search ---> * | in beam| Direction + * * |________| of Search + * * | + * * V */ @LargeTest @Test @@ -1077,14 +1190,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ____________ __________ * | focused | | focused | | Item | * __________ | - * Item | | Item | | in beam | * | Closer | |___________| |___________| |_________| * | - * Item | __________ * |_________| | Closer | * | Item | * ____________ |_________| * | | - * Item | - * * Direction | in beam | ---- Direction of Search ---> * of Search |___________| - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ____________ __________ * | focused | + * | focused | | Item | * __________ | Item | + * | Item | | in beam | * | Closer | |___________| + * |___________| |_________| * | Item | + * __________ * |_________| + * | Closer | * + * | Item | * ____________ + * |_________| * | | Item | + * * Direction | in beam | + * ---- Direction of Search ---> * of Search |___________| + * * | + * * V */ @LargeTest @Test @@ -1129,18 +1248,31 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { } /** - * _______________ * ^ _________ | Item in Beam | ____________ * | | Item | |______________| | - * focused | * Direction | in beam| | Item | * of Search |________| |___________| * | - * ___________ * | | Closer | * __________ | Item | * | Closer | ____________ |__________| * | - * Item | | focused | - * * |_________| | Item | <---- Direction of Search --- * |___________| * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _______________ * ____________ ____________ | Item in Beam | * | focused | | focused | - * |______________| * __________ | Item | | Item | * | Closer | |___________| |___________| * | - * Item | __________ * |_________| | Closer | * | Item | * _________ |_________| * | | Item | - * * Direction | in beam| ---- Direction of Search ---> * of Search |________| - * * | - * * V + * _______________ * ^ _________ + * | Item in Beam | ____________ * | | Item | + * |______________| | focused | * Direction | in beam| + * | Item | * of Search |________| + * |___________| * | + * ___________ * | + * | Closer | * __________ + * | Item | * | Closer | ____________ + * |__________| * | Item | | focused | + * * |_________| | Item | + * <---- Direction of Search --- * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _______________ * ____________ + * ____________ | Item in Beam | * | focused | + * | focused | |______________| * __________ | Item | + * | Item | * | Closer | |___________| + * |___________| * | Item | + * __________ * |_________| + * | Closer | * + * | Item | * _________ + * |_________| * | | Item | + * * Direction | in beam| + * ---- Direction of Search ---> * of Search |________| + * * | + * * V */ @LargeTest @Test @@ -1197,14 +1329,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ____________ _______________ * | focused | | focused | | Item in Beam | * - * __________ | Item | | Item | |______________| * | Closer | |___________| - * |___________| * | Item | __________ * |_________| | Closer | * | Item | * _________ - * |_________| * | | Item | - * * Direction | in beam| ---- Direction of Search ---> * of Search |________| - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ____________ _______________ * | focused | + * | focused | | Item in Beam | * __________ | Item | + * | Item | |______________| * | Closer | |___________| + * |___________| * | Item | + * __________ * |_________| + * | Closer | * + * | Item | * _________ + * |_________| * | | Item | + * * Direction | in beam| + * ---- Direction of Search ---> * of Search |________| + * * | + * * V */ @LargeTest @Test @@ -1261,13 +1399,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * ____________ | | _______________ * | focused | | focused | | Item in Beam | * - * __________ | Item | | Item | |______________| * | Closer | |___________| |___________| * | - * Item | __________ * |_________| | Closer | * | Item | * _______ |_________| * | | Item | - * * Direction | in | ---- Direction of Search ---> * of Search | Beam | - * * | |______| - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * ____________ + * | | _______________ * | focused | + * | focused | | Item in Beam | * __________ | Item | + * | Item | |______________| * | Closer | |___________| + * |___________| * | Item | + * __________ * |_________| + * | Closer | * + * | Item | * _______ + * |_________| * | | Item | + * * Direction | in | + * ---- Direction of Search ---> * of Search | Beam | + * * | |______| + * * V */ @LargeTest @Test @@ -1324,14 +1469,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| | Item | * <---- Direction of Search --- * |____________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * _____________ | focused | _______________ * | focused | | Item | | Item in - * Beam | * __________ | Item | |___________| |______________| * | Closer | |____________| - * __________ * | Item | | Closer | * |_________| | Item | * |_________| * _________ - * * | | Item | ---- Direction of Search ---> * Direction | in beam| - * * of Search |________| - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * _____________ + * | focused | _______________ * | focused | + * | Item | | Item in Beam | * __________ | Item | + * |___________| |______________| * | Closer | |____________| + * __________ * | Item | + * | Closer | * |_________| + * | Item | * + * |_________| * _________ + * * | | Item | + * ---- Direction of Search ---> * Direction | in beam| + * * of Search |________| + * * | + * * V */ @LargeTest @Test @@ -1388,14 +1539,21 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * |_________| * |_________| | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * ____________ | focused | * | focused | | Item | _______________ * __________ | - * Item | |___________| | | * | Closer | |___________| | Item in Beam | * | Item | - * |______________| * |_________| - * * - * __________ * | __________ | Closer | * Direction | Item | | Item | * of Search | in beam | - * |_________| * | |_________| - * * V ---- Direction of Search ---> * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * ____________ + * | focused | * | focused | + * | Item | _______________ * __________ | Item | + * |___________| | | * | Closer | |___________| + * | Item in Beam | * | Item | + * |______________| * |_________| + * * + * __________ * | __________ + * | Closer | * Direction | Item | + * | Item | * of Search | in beam | + * |_________| * | |_________| + * * V + * ---- Direction of Search ---> * + * * */ @LargeTest @Test @@ -1451,14 +1609,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused |_________| * <---- Direction of Search --- * | Item | * * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ - * * | focused | - * * ________| Item | ____________ __________ * | Closer |___________| | focused | | Item - * | * | Item | | Item | | in beam | * |________| |___________|______ |_________| * - * ____________ | Closer | * | | Item | | Item | * Direction | in beam | |_________| * of - * Search |___________| - * * | ---- Direction of Search ---> * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * * | focused | + * * ________| Item | + * ____________ __________ * | Closer |___________| + * | focused | | Item | * | Item | + * | Item | | in beam | * |________| + * |___________|______ |_________| * ____________ + * | Closer | * | | Item | + * | Item | * Direction | in beam | + * |_________| * of Search |___________| + * * | + * ---- Direction of Search ---> * V */ @LargeTest @Test @@ -1515,13 +1678,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| Item | * <---- Direction of Search --- * |__________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _______________ * ____________ ____________ | Item in Beam | * | focused | | focused | - * |______________| * _________| Item | | Item | * | Closer |___________| |___________|______ * - * | Item | | Closer | * |_________| | Item | * |_________| * | _________ - * * Direction | Item | ---- Direction of Search ---> * of Search | in beam| - * * | |________| - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _______________ * ____________ + * ____________ | Item in Beam | * | focused | + * | focused | |______________| * _________| Item | + * | Item | * | Closer |___________| + * |___________|______ * | Item | + * | Closer | * |_________| + * | Item | * + * |_________| * | _________ + * * Direction | Item | + * ---- Direction of Search ---> * of Search | in beam| + * * | |________| + * * V */ @LargeTest @Test @@ -1577,15 +1746,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| focused | * <---- Direction of Search --- * | Item | * * |______________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * _______________ - * * | focused | - * * _________| Item | ____________ _______________ * | Closer |______________| | focused | - * | Item in Beam | * | Item | | Item | |______________| * |_________| - * |___________|______ * | Closer | * | _________ | Item | * Direction | Item | - * |_________| * of Search | in beam| - * * | |________| - * * V ---- Direction of Search ---> * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * _______________ + * * | focused | + * * _________| Item | + * ____________ _______________ * | Closer |______________| + * | focused | | Item in Beam | * | Item | + * | Item | |______________| * |_________| + * |___________|______ * + * | Closer | * | _________ + * | Item | * Direction | Item | + * |_________| * of Search | in beam| + * * | |________| + * * V + * ---- Direction of Search ---> * */ @LargeTest @Test @@ -1642,13 +1816,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * |_________| focused | * * | Item | * * |_____________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * ______________ | | _______________ * | focused | | focused | | Item in Beam - * | * _________| Item | | Item | |______________| * | Closer |_____________| - * |___________|______ * | Item | | Closer | * |_________| | Item | * |_________| * _______ | - * * | Item | Direction ---- Direction of Search ---> * | in | of Search - * * | Beam | | - * * |______| V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * ______________ + * | | _______________ * | focused | + * | focused | | Item in Beam | * _________| Item | + * | Item | |______________| * | Closer |_____________| + * |___________|______ * | Item | + * | Closer | * |_________| + * | Item | * + * |_________| * _______ | + * * | Item | Direction + * ---- Direction of Search ---> * | in | of Search + * * | Beam | | + * * |______| V */ @LargeTest @Test @@ -1698,19 +1878,24 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * _______________ | focused | * | in beam| Direction * | Item in Beam | | Item | * |________| of Search * |______________| ___|___________| * | - * | Closer | * __________ | + * | Closer | * __________ | * | Item | * | Closer | * |_________| * | Item |____________ * * |_________| focused | * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * ____________ | focused | _______________ * | focused | | Item | | Item in Beam - * | * _________| Item | |___________|______ |______________| * | Closer |___________| | Closer - * | * | Item | | | Item | * |_________| Direction |_________| * _________ of Search - * * | Item | | - * * | in beam| V ---- Direction of Search ---> * |________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * ____________ + * | focused | _______________ * | focused | + * | Item | | Item in Beam | * _________| Item | + * |___________|______ |______________| * | Closer |___________| + * | Closer | * | Item | | + * | Item | * |_________| Direction + * |_________| * _________ of Search + * * | Item | | + * * | in beam| V + * ---- Direction of Search ---> * |________| */ @LargeTest @Test @@ -1766,13 +1951,18 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | focused | * <---- Direction of Search --- * |_________| Item | * * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * ____________ | focused | * | focused | | Item | _______________ * _________| - * Item | |___________|______ | Item in Beam | * | Closer |___________| | Closer | - * |______________| * | Item | | Item | * |_________| |_________| * | - * * _________ Direction ---- Direction of Search ---> * | Item | of Search - * * | in beam| | - * * |________| V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * ____________ + * | focused | * | focused | + * | Item | _______________ * _________| Item | + * |___________|______ | Item in Beam | * | Closer |___________| + * | Closer | |______________| * | Item | + * | Item | * |_________| + * |_________| * | + * * _________ Direction + * ---- Direction of Search ---> * | Item | of Search + * * | in beam| | + * * |________| V */ @LargeTest @Test @@ -1829,14 +2019,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * | Closer | * ____________ | Item | * | focused | __________ |_________| * | Item - * | | Closer | ______________________________ * |___________| | Item | | focused | | Item | * | - * | |_________| | Item | | in beam | * |___________| |___________|____|____________| * | Item | - * | - * * | in beam | Direction ---- Direction of Search ---> * |___________| of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * + * | Closer | * ____________ + * | Item | * | focused | __________ + * |_________| * | Item | | Closer | + * ______________________________ * |___________| | Item | + * | focused | | Item | * | | |_________| + * | Item | | in beam | * |___________| + * |___________|____|____________| * | Item | | + * * | in beam | Direction + * ---- Direction of Search ---> * |___________| of Search + * * | + * * V */ @MediumTest @Test @@ -1895,13 +2090,18 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * | ________| | Closer | ______________________________ * |__|________| | Item | - * | focused | | Item in Beam | * | Item | |_________| | Item |__|______________| * | in beam| | - * |______________| * |________| Direction - * * of Search ---- Direction of Search ---> * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * | ________| | Closer | + * ______________________________ * |__|________| | Item | + * | focused | | Item in Beam | * | Item | |_________| + * | Item |__|______________| * | in beam| | + * |______________| * |________| Direction + * * of Search + * ---- Direction of Search ---> * | + * * V */ @LargeTest @Test @@ -1959,14 +2159,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | |_________| * <---- Direction of Search --- * | Item | * * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * | Closer | * ____________ | Item | * | focused | |_________| * | Item | - * __________ _______________ * |_________ | | Closer | | focused __|_______________ * - * |________|__| | Item | | Item | | Item in Beam | * | Item | |_________| - * |___________|__|______________| * | in beam| | - * * |________| Direction ---- Direction of Search ---> * of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * + * | Closer | * ____________ + * | Item | * | focused | + * |_________| * | Item | __________ + * _______________ * |_________ | | Closer | + * | focused __|_______________ * |________|__| | Item | + * | Item | | Item in Beam | * | Item | |_________| + * |___________|__|______________| * | in beam| | + * * |________| Direction + * ---- Direction of Search ---> * of Search + * * | + * * V */ @LargeTest @Test @@ -2023,13 +2228,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | |_________| * <---- Direction of Search --- * | Item | * * |____________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | __________ - * |_________| * | _________ | | Closer | _______________ * |_|________|_| | Item | | - * __|________________ * | Item | |_________| | focused | | Item in Beam | * | in beam| | | Item - * |__|_______________| * |________| Direction |______________| * of Search - * * | - * * V ---- Direction of Search ---> * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | __________ + * |_________| * | _________ | | Closer | + * _______________ * |_|________|_| | Item | + * | __|________________ * | Item | |_________| + * | focused | | Item in Beam | * | in beam| | + * | Item |__|_______________| * |________| Direction + * |______________| * of Search + * * | + * * V + * ---- Direction of Search ---> * */ @LargeTest @Test @@ -2085,12 +2296,16 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item |_________| * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused |__________ | Item | * | Item | Closer | - * ____|_________|______________ * |___________| Item | | | focused | | Item | * | |_________| - * Direction | Item | | in beam | * |___________| of Search |___________|___|____________| * | - * Item | | - * * | in beam | V ---- Direction of Search ---> * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused |__________ + * | Item | * | Item | Closer | + * ____|_________|______________ * |___________| Item | | + * | focused | | Item | * | |_________| Direction + * | Item | | in beam | * |___________| of Search + * |___________|___|____________| * | Item | | + * * | in beam | V + * ---- Direction of Search ---> * |___________| */ @LargeTest @Test @@ -2147,12 +2362,18 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ __________ * | focused | | Closer | * | Item |__________ | Item | * | - * ________| | _______|_________|____________ * |__|________| Closer | | | focused | | - * Item in Beam | * | Item | Item | Direction | Item |__|______________| * | in - * beam|_________| of Search |______________| * |________| | - * * V ---- Direction of Search ---> * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * __________ * | focused | + * | Closer | * | Item |__________ + * | Item | * | ________| | + * _______|_________|____________ * |__|________| Closer | | + * | focused | | Item in Beam | * | Item | Item | Direction + * | Item |__|______________| * | in beam|_________| of Search + * |______________| * |________| | + * * V + * ---- Direction of Search ---> * + * * */ @LargeTest @Test @@ -2208,12 +2429,16 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused |_________| * <---- Direction of Search --- * | Item | * * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * ____________ | Closer | * | focused | | Item | * | Item | _______|_________| * | - * |__________ | focused __|_______________ * |_________ | Closer | | | Item | | Item in Beam - * | * |________|__| Item | Direction |___________|__|______________| * | Item | |_________| of - * Search - * * | in beam| | ---- Direction of Search ---> * |________| V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * ____________ + * | Closer | * | focused | + * | Item | * | Item | + * _______|_________| * | |__________ + * | focused __|_______________ * |_________ | Closer | | + * | Item | | Item in Beam | * |________|__| Item | Direction + * |___________|__|______________| * | Item | |_________| of Search + * * | in beam| | + * ---- Direction of Search ---> * |________| V */ @LargeTest @Test @@ -2269,13 +2494,18 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused |_________| * <---- Direction of Search --- * | Item | * * |____________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * _____________ | Closer | * | focused | | Item | * | Item |__________ - * _______|_________| * | _________ | Closer | | __|________________ * |_|________|_| Item | | - * focused | | Item in Beam | * | Item | |_________| | | Item |__|_______________| * | in beam| - * Direction |______________| * |________| of Search - * * | - * * V ---- Direction of Search ---> * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * _____________ + * | Closer | * | focused | + * | Item | * | Item |__________ + * _______|_________| * | _________ | Closer | + * | __|________________ * |_|________|_| Item | + * | focused | | Item in Beam | * | Item | |_________| | + * | Item |__|_______________| * | in beam| Direction + * |______________| * |________| of Search + * * | + * * V + * ---- Direction of Search ---> * */ @LargeTest @Test @@ -2332,14 +2562,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _____________________________ * | focused | | Item | * ____________ | Item | | in beam | * - * __________ | focused | |___________|___|____________| * | Closer | | Item | __________ * | - * Item | |___________| | Closer | * |_________| | | | Item | * |___________| |_________| * | | - * Item | - * * Direction | in beam | ---- Direction of Search ---> * of Search |___________| - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _____________________________ * + * | focused | | Item | * ____________ + * | Item | | in beam | * __________ | focused | + * |___________|___|____________| * | Closer | | Item | + * __________ * | Item | |___________| + * | Closer | * |_________| | | + * | Item | * |___________| + * |_________| * | | Item | + * * Direction | in beam | + * ---- Direction of Search ---> * of Search |___________| + * * | + * * V */ @LargeTest @Test @@ -2398,13 +2633,18 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ______________________________ * ____________ | focused | | Item in Beam | * __________ | - * focused | | Item |__|______________| * | Closer | | Item | |______________| * | Item | | - * ________| __________ * | | |__|________| | Closer | * |_________| | Item | | Item | * | | in - * beam| |_________| * Direction |________| - * * of Search ---- Direction of Search ---> * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ______________________________ * ____________ + * | focused | | Item in Beam | * __________ | focused | + * | Item |__|______________| * | Closer | | Item | + * |______________| * | Item | | ________| + * __________ * | | |__|________| + * | Closer | * |_________| | Item | + * | Item | * | | in beam| + * |_________| * Direction |________| + * * of Search + * ---- Direction of Search ---> * | + * * V */ @LargeTest @Test @@ -2462,14 +2702,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| | focused | * <---- Direction of Search --- * | Item | * * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _______________ * | focused __|_______________ * ____________ | Item | | Item in Beam | * | - * focused | |___________|__|______________| * __________ | Item | __________ * | Closer | - * |_________ | | Closer | * | Item | |________|__| | Item | * |_________| | Item | - * |_________| * | | in beam| - * * Direction |________| ---- Direction of Search ---> * of Search - * * | - * * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _______________ * + * | focused __|_______________ * ____________ + * | Item | | Item in Beam | * | focused | + * |___________|__|______________| * __________ | Item | + * __________ * | Closer | |_________ | + * | Closer | * | Item | |________|__| + * | Item | * |_________| | Item | + * |_________| * | | in beam| + * * Direction |________| + * ---- Direction of Search ---> * of Search + * * | + * * V */ @LargeTest @Test @@ -2526,12 +2771,18 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| | focused | * <---- Direction of Search --- * | Item | * * |____________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _______________ * ____________ | __|________________ * | focused | | focused | | Item in Beam - * | * __________ | Item | | Item |__|_______________| * | Closer | | _________ | - * |______________| * | Item | |_|________|_| __________ * |_________| | Item | | Closer | * | | - * in beam| | Item | * Direction |________| |_________| * of Search - * * | ---- Direction of Search ---> * V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _______________ * ____________ + * | __|________________ * | focused | + * | focused | | Item in Beam | * __________ | Item | + * | Item |__|_______________| * | Closer | | _________ | + * |______________| * | Item | |_|________|_| + * __________ * |_________| | Item | + * | Closer | * | | in beam| + * | Item | * Direction |________| + * |_________| * of Search + * * | + * ---- Direction of Search ---> * V */ @LargeTest @Test @@ -2586,12 +2837,16 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * |_________| Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _____________________________ * ____________ | focused | | Item | * _________| focused | | - * Item | | in beam | * | Closer | Item | |___________|___|____________| * | Item |___________| - * | Closer | * |_________| | | | Item | * |___________| Direction |_________| * | Item | of - * Search - * * | in beam | | ---- Direction of Search ---> * |___________| V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _____________________________ * ____________ + * | focused | | Item | * _________| focused | + * | Item | | in beam | * | Closer | Item | + * |___________|___|____________| * | Item |___________| + * | Closer | * |_________| | | + * | Item | * |___________| Direction + * |_________| * | Item | of Search + * * | in beam | | + * ---- Direction of Search ---> * |___________| V */ @LargeTest @Test @@ -2645,12 +2900,16 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * |_________| * |_________| focused | | * * | Item | * <---- Direction of Search --- * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ______________________________ * ____________ | focused | | Item in Beam | * __________| - * focused | | Item |__|______________| * | Closer | | |______________|___ * | Item | ________| - * | | Closer | * | |__|________| Direction | Item | * |_________| | | of Search |_________| * | - * Item | | - * * | in beam| V ---- Direction of Search ---> * |________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ______________________________ * ____________ + * | focused | | Item in Beam | * __________| focused | + * | Item |__|______________| * | Closer | | + * |______________|___ * | Item | ________| | + * | Closer | * | |__|________| Direction + * | Item | * |_________| | | of Search + * |_________| * | Item | | + * * | in beam| V + * ---- Direction of Search ---> * |________| */ @LargeTest @Test @@ -2704,12 +2963,16 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * |_________| * |_________| focused | * * | Item | * <---- Direction of Search --- * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _______________ * ____________ | focused __|_______________ * _________| focused | | Item | | - * Item in Beam | * | | Item | |___________|__|______________| * | Closer |_________ | | Closer - * | * | Item |________|__| | | Item | * |_________| | Direction |_________| * | Item | of - * Search - * * | in beam| | ---- Direction of Search ---> * |________| V + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _______________ * ____________ + * | focused __|_______________ * _________| focused | + * | Item | | Item in Beam | * | | Item | + * |___________|__|______________| * | Closer |_________ | + * | Closer | * | Item |________|__| | + * | Item | * |_________| | Direction + * |_________| * | Item | of Search + * * | in beam| | + * ---- Direction of Search ---> * |________| V */ @LargeTest @Test @@ -2764,13 +3027,17 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * |_________| * |____________| | * * * <---- Direction of Search --- * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _______________ * _____________ | __|________________ * | focused | | focused | | Item in - * Beam | * _________| Item | | | Item |__|_______________| * | Closer | _________ | Direction - * |______________|___ * | Item |_|________|_| of Search | Closer | * |_________| | Item | | | - * Item | * | in beam| V |_________| * |________| - * * - * ---- Direction of Search ---> * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _______________ * _____________ + * | __|________________ * | focused | + * | focused | | Item in Beam | * _________| Item | | + * | Item |__|_______________| * | Closer | _________ | Direction + * |______________|___ * | Item |_|________|_| of Search + * | Closer | * |_________| | Item | | + * | Item | * | in beam| V + * |_________| * |________| + * * + * ---- Direction of Search ---> * */ @LargeTest @Test @@ -2827,13 +3094,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | of Search * <---- Direction of Search --- * |___________| | * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * | Closer | * | Item | * ____________ | |_________| * | focused | Direction - * ____________ __________ * | Item | of Search | focused | | Item | * |___________| | | Item | - * | in beam | * V |___________| |_________| * - * * ____________ __________ ---- Direction of Search ---> * | Item | | Closer | - * * | in beam | | Item | - * * |___________| |_________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * + * | Closer | * + * | Item | * ____________ | + * |_________| * | focused | Direction + * ____________ __________ * | Item | of Search + * | focused | | Item | * |___________| | + * | Item | | in beam | * V + * |___________| |_________| * + * * ____________ __________ + * ---- Direction of Search ---> * | Item | | Closer | + * * | in beam | | Item | + * * |___________| |_________| */ @MediumTest @Test @@ -2890,11 +3163,17 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * of Search | Item | * <---- Direction of Search --- * | |___________| * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ __________ * | ____________ | focused | | Item | * Direction | focused | | Item - * | | in beam | * of Search | Item | |___________| |_________| * | |___________| __________ * V - * | Closer | * | Item | * __________ ____________ |_________| * | Closer | | Item | - * * | Item | | in beam | ---- Direction of Search ---> * |_________| |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ __________ * | ____________ + * | focused | | Item | * Direction | focused | + * | Item | | in beam | * of Search | Item | + * |___________| |_________| * | |___________| + * __________ * V + * | Closer | * + * | Item | * __________ ____________ + * |_________| * | Closer | | Item | + * * | Item | | in beam | + * ---- Direction of Search ---> * |_________| |___________| */ @LargeTest @Test @@ -2951,13 +3230,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | of Search * <---- Direction of Search --- * |___________| | * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * _____________ * | Closer | * | Item | * ____________ | |____________| * | focused | Direction - * ____________ __________ * | Item | of Search | focused | | Item | * |___________| | | Item | - * | in beam | * V |___________| |_________| * __________ - * * ____________ | | ---- Direction of Search ---> * | Item | | Closer | - * * | in beam | | Item | - * * |___________| |_________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * _____________ * + * | Closer | * + * | Item | * ____________ | + * |____________| * | focused | Direction + * ____________ __________ * | Item | of Search + * | focused | | Item | * |___________| | + * | Item | | in beam | * V + * |___________| |_________| * __________ + * * ____________ | | + * ---- Direction of Search ---> * | Item | | Closer | + * * | in beam | | Item | + * * |___________| |_________| */ @MediumTest @Test @@ -3014,12 +3299,17 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * of Search | Item | * <---- Direction of Search --- * | |___________| * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ __________ * | ____________ | focused | | Item | * Direction | focused | | Item - * | | in beam | * of Search | Item | |___________| |_________| * | |___________| - * _____________ * V | Closer | * __________ | Item | * | | ____________ |____________| * | - * Closer | | Item | - * * | Item | | in beam | ---- Direction of Search ---> * |_________| |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ __________ * | ____________ + * | focused | | Item | * Direction | focused | + * | Item | | in beam | * of Search | Item | + * |___________| |_________| * | |___________| + * _____________ * V + * | Closer | * __________ + * | Item | * | | ____________ + * |____________| * | Closer | | Item | + * * | Item | | in beam | + * ---- Direction of Search ---> * |_________| |___________| */ @LargeTest @Test @@ -3076,13 +3366,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | of Search * <---- Direction of Search --- * |___________| | * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ___________ * | Closer | * | Item | * ____________ | |__________| * | focused | Direction - * ____________ __________ * | Item | of Search | focused | | Item | * |___________| | | Item | - * | in beam | * V |___________| |_________| * __________ - * * ____________ | Closer | ---- Direction of Search ---> * | Item | | Item | - * * | in beam | |_________| - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ___________ * + * | Closer | * + * | Item | * ____________ | + * |__________| * | focused | Direction + * ____________ __________ * | Item | of Search + * | focused | | Item | * |___________| | + * | Item | | in beam | * V + * |___________| |_________| * __________ + * * ____________ | Closer | + * ---- Direction of Search ---> * | Item | | Item | + * * | in beam | |_________| + * * |___________| */ @MediumTest @Test @@ -3139,11 +3435,17 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * of Search | Item | * * | |___________| * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ __________ * | ____________ | focused | | Item | * Direction | focused | | Item - * | | in beam | * of Search | Item | |___________| |_________| * | |___________| ___________ * - * V | Closer | * __________ | Item | * | Closer | ____________ |__________| * | Item | | Item | - * * |_________| | in beam | ---- Direction of Search ---> * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ __________ * | ____________ + * | focused | | Item | * Direction | focused | + * | Item | | in beam | * of Search | Item | + * |___________| |_________| * | |___________| + * ___________ * V + * | Closer | * __________ + * | Item | * | Closer | ____________ + * |__________| * | Item | | Item | + * * |_________| | in beam | + * ---- Direction of Search ---> * |___________| */ @LargeTest @Test @@ -3201,13 +3503,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * | Item | of Search * * |___________| | * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ___________ * ____________ | | Closer | * | focused | Direction | Item | * | Item | of Search - * |__________| * |___________| | ____________ __________ * V | focused | | Item | * _________ | - * Item | | in beam | * | Closer | |___________| |_________| * | Item | - * * ____________ |________| ---- Direction of Search ---> * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ___________ * ____________ | + * | Closer | * | focused | Direction + * | Item | * | Item | of Search + * |__________| * |___________| | + * ____________ __________ * V + * | focused | | Item | * _________ + * | Item | | in beam | * | Closer | + * |___________| |_________| * | Item | + * * ____________ |________| + * ---- Direction of Search ---> * | Item | + * * | in beam | + * * |___________| */ @MediumTest @Test @@ -3275,13 +3583,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * Direction | focused | * * of Search | Item | * * | |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ __________ * | ____________ | focused | | Item | * Direction | focused | | Item - * | | in beam | * of Search | Item | |___________| |_________| * | |___________| ___________ * - * V | Closer | * _________ | Item | * | Closer | |__________| * | Item | - * * |________| ____________ ---- Direction of Search ---> * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ __________ * | ____________ + * | focused | | Item | * Direction | focused | + * | Item | | in beam | * of Search | Item | + * |___________| |_________| * | |___________| + * ___________ * V + * | Closer | * _________ + * | Item | * | Closer | + * |__________| * | Item | + * * |________| ____________ + * ---- Direction of Search ---> * | Item | + * * | in beam | + * * |___________| */ @LargeTest @Test @@ -3350,13 +3664,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * of Search * * | * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ___________ * | | Closer | * Direction | Item | * ____________ of Search |__________| * | - * focused | | ____________ __________ * | Item | V | focused | | Item | * |___________| - * _________ | Item | | in beam | * | Closer | |___________| |_________| * | Item | - * * ____________ |________| ---- Direction of Search ---> * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ___________ * | + * | Closer | * Direction + * | Item | * ____________ of Search + * |__________| * | focused | | + * ____________ __________ * | Item | V + * | focused | | Item | * |___________| _________ + * | Item | | in beam | * | Closer | + * |___________| |_________| * | Item | + * * ____________ |________| + * ---- Direction of Search ---> * | Item | + * * | in beam | + * * |___________| */ @MediumTest @Test @@ -3425,15 +3745,21 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * | Item | Direction * * |___________| of Search * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * | Closer | * | | Item | * ____________ Direction |_________| * | focused | of - * Search ____________ __________ * | Item | | | focused | | Item | * |___________| V | Item | | - * in beam | * _________ |___________| |_________| * | Closer | - * * | Item | ---- Direction of Search ---> * |________| - * * ____________ - * * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * + * | Closer | * | + * | Item | * ____________ Direction + * |_________| * | focused | of Search + * ____________ __________ * | Item | | + * | focused | | Item | * |___________| V + * | Item | | in beam | * _________ + * |___________| |_________| * | Closer | + * * | Item | + * ---- Direction of Search ---> * |________| + * * ____________ + * * | Item | + * * | in beam | + * * |___________| */ @LargeTest @Test @@ -3502,14 +3828,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * |___________| Direction * * of Search * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * __________ * | | Closer | * Direction | Item | * ____________ of Search |_________| * | - * focused | | ____________ __________ * | Item | V | focused | | Item | * |___________| - * _________ | Item | | in beam | * | Closer | |___________| |_________| * | Item | - * * |________| ---- Direction of Search ---> * ____________ - * * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * __________ * | + * | Closer | * Direction + * | Item | * ____________ of Search + * |_________| * | focused | | + * ____________ __________ * | Item | V + * | focused | | Item | * |___________| _________ + * | Item | | in beam | * | Closer | + * |___________| |_________| * | Item | + * * |________| + * ---- Direction of Search ---> * ____________ + * * | Item | + * * | in beam | + * * |___________| */ @LargeTest @Test @@ -3577,13 +3909,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * <---- Direction of Search --- * Direction |___________| * * of Search * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ __________ * | | focused | | Item | * Direction | Item | | in beam | * of Search - * ____________ |___________| |_________| * | | focused | ___________ * V | Item | | Closer | * - * _________ |___________| | Item | * | Closer | |__________| * | Item | - * * |________| ____________ ---- Direction of Search ---> * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ __________ * | + * | focused | | Item | * Direction + * | Item | | in beam | * of Search ____________ + * |___________| |_________| * | | focused | + * ___________ * V | Item | + * | Closer | * _________ |___________| + * | Item | * | Closer | + * |__________| * | Item | + * * |________| ____________ + * ---- Direction of Search ---> * | Item | + * * | in beam | + * * |___________| */ @LargeTest @Test @@ -3652,15 +3990,21 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * Direction | Item | * * of Search |___________| * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ __________ * | focused | | Item | * | | Item | | in beam | * Direction - * ____________ |___________| |_________| * of Search | focused | __________ * | | Item | | - * Closer | * V |___________| | Item | * _________ |_________| * | Closer | - * * | Item | ---- Direction of Search ---> * |________| - * * ____________ - * * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ __________ * + * | focused | | Item | * | + * | Item | | in beam | * Direction ____________ + * |___________| |_________| * of Search | focused | + * __________ * | | Item | + * | Closer | * V |___________| + * | Item | * _________ + * |_________| * | Closer | + * * | Item | + * ---- Direction of Search ---> * |________| + * * ____________ + * * | Item | + * * | in beam | + * * |___________| */ @LargeTest @Test @@ -3729,14 +4073,20 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * Direction |___________| * * of Search * * | - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ __________ * | | focused | | Item | * Direction | Item | | in beam | * of Search - * ____________ |___________| |_________| * | | focused | __________ * V | Item | | Closer | * - * _________ |___________| | Item | * | Closer | |_________| * | Item | - * * |________| - * * ____________ ---- Direction of Search ---> * | Item | - * * | in beam | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ __________ * | + * | focused | | Item | * Direction + * | Item | | in beam | * of Search ____________ + * |___________| |_________| * | | focused | + * __________ * V | Item | + * | Closer | * _________ |___________| + * | Item | * | Closer | + * |_________| * | Item | + * * |________| + * * ____________ + * ---- Direction of Search ---> * | Item | + * * | in beam | + * * |___________| */ @LargeTest @Test @@ -3792,27 +4142,31 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { } /** - * ____________ ____________ ____________ * ____________ | In Beam | | In Beam | | focused | * | - * In Beam | | Farther | | Closer | | Item | * | Farther | |___________| |___________| - * |___________| * |___________| ^ - * * ____________ | <---- Direction of Search --- * | In Beam | Direction - * * | Closer | of Search - * * |___________| | - * * ____________ | - * * | focused | - * * | Item | - * * |___________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ ____________ ____________ * ____________ | focused | | In Beam | | In Beam | * | - * focused | | Item | | Closer | | Farther | * | Item | |___________| |___________| - * |___________| * |___________| | - * * ____________ | ---- Direction of Search ---> * | In Beam | Direction - * * | Closer | of Search - * * |___________| | - * * ____________ v - * * | In Beam | - * * | Farther | - * * |___________| + * ____________ ____________ ____________ * ____________ + * | In Beam | | In Beam | | focused | * | In Beam | + * | Farther | | Closer | | Item | * | Farther | + * |___________| |___________| |___________| * |___________| ^ + * * ____________ | + * <---- Direction of Search --- * | In Beam | Direction + * * | Closer | of Search + * * |___________| | + * * ____________ | + * * | focused | + * * | Item | + * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ ____________ ____________ * ____________ + * | focused | | In Beam | | In Beam | * | focused | + * | Item | | Closer | | Farther | * | Item | + * |___________| |___________| |___________| * |___________| | + * * ____________ | + * ---- Direction of Search ---> * | In Beam | Direction + * * | Closer | of Search + * * |___________| | + * * ____________ v + * * | In Beam | + * * | Farther | + * * |___________| */ @MediumTest @Test @@ -3871,13 +4225,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | * * | Item | * * |_________________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * __________________ | | * | focused | | | ____________ * | Item | | focused | | - * In Beam | * |_________________| | | Item | ____________ | Farther | * _____________ | | | | | - * |___________| * | In Beam | Direction | | | In Beam | * | Closer | of Search |___________| | - * Closer | * |____________| | |___________| * ___________ v - * * | In Beam | ---- Direction of Search ---> * | Farther | - * * |__________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * __________________ + * | | * | focused | + * | | ____________ * | Item | + * | focused | | In Beam | * |_________________| | + * | Item | ____________ | Farther | * _____________ | + * | | | | |___________| * | In Beam | Direction + * | | | In Beam | * | Closer | of Search + * |___________| | Closer | * |____________| | + * |___________| * ___________ v + * * | In Beam | + * ---- Direction of Search ---> * | Farther | + * * |__________| */ @LargeTest @Test @@ -3934,15 +4294,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | * * | Item | * * |__________________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ___________________ ____________ * | focused | | | ____________ * | Item | | focused | - * ____________ | In Beam | * |__________________| | | Item | | In Beam | | Farther | * - * _____________ | | | | Closer | |___________| * | In Beam | Direction |___________| - * |___________| * | Closer | of Search - * * |____________| | ---- Direction of Search ---> * ___________ v - * * | In Beam | - * * | Farther | - * * |__________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ___________________ + * ____________ * | focused | + * | | ____________ * | Item | + * | focused | ____________ | In Beam | * |__________________| | + * | Item | | In Beam | | Farther | * _____________ | + * | | | Closer | |___________| * | In Beam | Direction + * |___________| |___________| * | Closer | of Search + * * |____________| | + * ---- Direction of Search ---> * ___________ v + * * | In Beam | + * * | Farther | + * * |__________| */ @LargeTest @Test @@ -3999,15 +4363,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | * * | Item | * * |_______________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ________________ ____________ * | focused | | | ____________ ____________ * | Item | | - * focused | | In Beam | | In Beam | * |_______________| | | Item | | Closer | | Farther - * | * ____________ | | | |___________| |___________| * | In Beam | Direction - * |___________| * | Closer | of Search - * * |___________| | ---- Direction of Search ---> * ____________ v - * * | In Beam | - * * | Farther | - * * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ________________ + * ____________ * | focused | + * | | ____________ ____________ * | Item | + * | focused | | In Beam | | In Beam | * |_______________| | + * | Item | | Closer | | Farther | * ____________ | + * | | |___________| |___________| * | In Beam | Direction + * |___________| * | Closer | of Search + * * |___________| | + * ---- Direction of Search ---> * ____________ v + * * | In Beam | + * * | Farther | + * * |___________| */ @LargeTest @Test @@ -4065,14 +4433,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | * * | Item | * * |___________________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________________ ____________ ____________ * | focused | | | | In Beam | - * ____________ * | Item | | focused | | Closer | | In Beam | * |___________________| | | - * Item | |___________| | Farther | * ____________ | | | |___________| * | In Beam | - * Direction | | * | Closer | of Search |___________| * |___________| | - * * ___________ v ---- Direction of Search ---> * | In Beam | - * * | Farther | - * * |__________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________________ + * ____________ ____________ * | focused | + * | | | In Beam | ____________ * | Item | + * | focused | | Closer | | In Beam | * |___________________| | + * | Item | |___________| | Farther | * ____________ | + * | | |___________| * | In Beam | Direction + * | | * | Closer | of Search + * |___________| * |___________| | + * * ___________ v + * ---- Direction of Search ---> * | In Beam | + * * | Farther | + * * |__________| */ @LargeTest @Test @@ -4130,14 +4503,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | focused | * <---- Direction of Search --- * | Item | * * |__________________| - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * ____________ * ___________________ ____________ | | * | focused | | | | In Beam | - * ____________ * | Item | | focused | | Closer | | In Beam | * |__________________| | | Item | - * |___________| | Farther | * ____________ | | | |___________| * | In Beam | Direction | | * | - * Closer | of Search |___________| * |___________| | - * * ___________ v - * * | In Beam | ---- Direction of Search ---> * | Farther | - * * |__________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ____________ * ___________________ + * ____________ | | * | focused | + * | | | In Beam | ____________ * | Item | + * | focused | | Closer | | In Beam | * |__________________| | + * | Item | |___________| | Farther | * ____________ | + * | | |___________| * | In Beam | Direction + * | | * | Closer | of Search + * |___________| * |___________| | + * * ___________ v + * * | In Beam | + * ---- Direction of Search ---> * | Farther | + * * |__________| */ @LargeTest @Test @@ -4195,12 +4573,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ---- Direction of Search ---> * | focused | ____________ * | Item | | - * focused | * |___________| | Item | * ____________ |___________| * | | ____________ * | - * | Closer | | | * | |___________| ____________ | Farther | * Direction ____________ | | - * |___________| * of Search | | | Closer | * | | Farther | |___________| * v - * |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ---- Direction of Search ---> * | focused | + * ____________ * | Item | + * | focused | * |___________| + * | Item | * ____________ + * |___________| * | | + * ____________ * | | Closer | + * | | * | |___________| + * ____________ | Farther | * Direction ____________ + * | | |___________| * of Search | | + * | Closer | * | | Farther | + * |___________| * v |___________| */ @MediumTest @Test @@ -4257,13 +4642,19 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ---- Direction of Search ---> * | focused | ____________ * | Item | | - * focused | * |___________| | Item | * ____________ |___________| * | | ____________ - * ____________ * | | Closer | | | | | * | |___________| | Closer | | Farther | * - * Direction ____________ |___________| |___________| * of Search | | - * * | | Farther | - * * v |___________| + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ---- Direction of Search ---> * | focused | + * ____________ * | Item | + * | focused | * |___________| + * | Item | * ____________ + * |___________| * | | + * ____________ ____________ * | | Closer | + * | | | | * | |___________| + * | Closer | | Farther | * Direction ____________ + * |___________| |___________| * of Search | | + * * | | Farther | + * * v |___________| */ @LargeTest @Test @@ -4307,16 +4698,32 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { } /** - * ____________ * ____________ ^ | | * | | | | Farther | ____________ * | Farther | Direction - * |___________| | | * |___________| of Search | Closer | * ____________ | |___________| * | | | - * ____________ * | Closer | | focused | * |___________| | Item | * ____________ |___________| * - * | focused | - * * | Item | <---- Direction of Search --- * |___________| * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ---- Direction of Search ---> * | focused | ____________ * | Item | | focused - * | * |___________| | Item | * ____________ |___________| * | | ____________ * | | Closer | | - * | * | |___________| | Closer | ____________ * Direction ____________ |___________| | | * of - * Search | | | Farther | * | | Farther | |___________| * v |___________| + * ____________ * ____________ ^ + * | | * | | | + * | Farther | ____________ * | Farther | Direction + * |___________| | | * |___________| of Search + * | Closer | * ____________ | + * |___________| * | | | + * ____________ * | Closer | + * | focused | * |___________| + * | Item | * ____________ + * |___________| * | focused | + * * | Item | + * <---- Direction of Search --- * |___________| + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ---- Direction of Search ---> * | focused | + * ____________ * | Item | + * | focused | * |___________| + * | Item | * ____________ + * |___________| * | | + * ____________ * | | Closer | + * | | * | |___________| + * | Closer | ____________ * Direction ____________ + * |___________| | | * of Search | | + * | Farther | * | | Farther | + * |___________| * v |___________| */ @LargeTest @Test @@ -4360,25 +4767,33 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { } /** - * ____________ | | | Farther | ^ |___________| | ____________ Direction | | of Search | Closer - * | | |___________| | ____________ | focused | | Item | |___________| - * - * <---- Direction of Search --- - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * - * ---- Direction of Search ---> - * ____________ - * | focused | - * | Item | - * |___________| - * ____________ | - * | | | - * | Closer | Direction - * |___________| of Search - * ____________ | - * | | v - * | Farther | - * |___________| + * ____________ + * | | + * | Farther | ^ + * |___________| | + * ____________ Direction + * | | of Search + * | Closer | | + * |___________| | + * ____________ + * | focused | + * | Item | + * |___________| + * <---- Direction of Search --- + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * ---- Direction of Search ---> + * ____________ + * | focused | + * | Item | + * |___________| + * ____________ | + * | | | + * | Closer | Direction + * |___________| of Search + * ____________ | + * | | v + * | Farther | + * |___________| */ @LargeTest @Test @@ -4429,12 +4844,21 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ---- Direction of Search ---> * | focused | ____________ * | Item | | - * focused | * |___________| | Item | * ____________ |___________| * | | ____________ * | - * | Closer | ____________ | | * | |___________| | | | Closer | * Direction | Farther | - * |___________| * of Search |___________| ____________ * | | | * v | Farther | * - * |___________| * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ---- Direction of Search ---> * | focused | + * ____________ * | Item | + * | focused | * |___________| + * | Item | * ____________ + * |___________| * | | + * ____________ * | | Closer | ____________ + * | | * | |___________| | | + * | Closer | * Direction | Farther | + * |___________| * of Search |___________| + * ____________ * | + * | | * v + * | Farther | * + * |___________| * */ @LargeTest @Test @@ -4493,12 +4917,21 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ---- Direction of Search ---> * | focused | ____________ * | Item | | - * focused | * |___________| | Item | * ____________ ____________ |___________| * | | | | - * ____________ * | | Closer | | Farther | | | * | |___________| |___________| | Closer - * | * Direction |___________| * of Search ____________ * | | | * v | Farther | * - * |___________| * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ---- Direction of Search ---> * | focused | + * ____________ * | Item | + * | focused | * |___________| + * | Item | * ____________ ____________ + * |___________| * | | | | + * ____________ * | | Closer | | Farther | + * | | * | |___________| |___________| + * | Closer | * Direction + * |___________| * of Search + * ____________ * | + * | | * v + * | Farther | * + * |___________| * */ @LargeTest @Test @@ -4557,12 +4990,21 @@ class TwoDimensionalFocusTraversalThreeItemsTest(param: Param) { * * | Item | * <---- Direction of Search --- * |___________| * * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * * ____________ ---- Direction of Search ---> * | focused | ____________ * | Item | | - * focused | * |___________| | Item | * ____________ |___________| * | | ____________ * | - * ____________ | Farther | | | * | | | |___________| | Closer | * Direction | Closer | - * |___________| * of Search |___________| ____________ * | | | * v | Farther | * - * |___________| * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * ____________ + * ---- Direction of Search ---> * | focused | + * ____________ * | Item | + * | focused | * |___________| + * | Item | * ____________ + * |___________| * | | + * ____________ * | ____________ | Farther | + * | | * | | | |___________| + * | Closer | * Direction | Closer | + * |___________| * of Search |___________| + * ____________ * | + * | | * v + * | Farther | * + * |___________| * */ @MediumTest @Test diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifierTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifierTest.kt index b208f50cfa939..c162a9048855e 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifierTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifierTest.kt @@ -1579,7 +1579,7 @@ class NestedScrollModifierTest { } rule.runOnIdle { - assertThat(innerDispatcher.lastKnownValidParentNode).isNull() + assertThat(innerDispatcher.lastKnownParentNode).isNull() assertThat(innerDispatcher.nestedScrollNode?.parentNestedScrollNode) .isEqualTo(outerDispatcher.nestedScrollNode) } @@ -1590,7 +1590,7 @@ class NestedScrollModifierTest { rule.runOnIdle { // the inner node's parent is the outer node - assertThat(innerDispatcher.lastKnownValidParentNode) + assertThat(innerDispatcher.lastKnownParentNode) .isEqualTo(outerDispatcher.nestedScrollNode) assertThat(innerDispatcher.nestedScrollNode?.parentNestedScrollNode).isNull() } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt index a801e1840bdf9..be8704a5bd63e 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt @@ -18,8 +18,6 @@ package androidx.compose.ui.input.pointer import android.content.Context import android.os.Build -import android.os.Handler -import android.os.Looper import android.view.InputDevice import android.view.MotionEvent import android.view.MotionEvent.ACTION_BUTTON_PRESS @@ -55,6 +53,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.AbsoluteAlignment @@ -86,15 +85,18 @@ import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastForEach import androidx.compose.ui.viewinterop.AndroidView import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import androidx.testutils.waitForFutureFrame import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -715,25 +717,89 @@ class AndroidPointerInputTest { } } + /* + * Tests that a long press is NOT triggered when an up event (following a down event) isn't + * executed right away because the UI thread is delayed past the long press timeout. + * + * Note: This test is a bit complicated because it needs to properly execute events in order + * using multiple coroutine delay()s and Thread.sleep() in the main thread. + * + * Expected behavior: When the UI thread wakes up, the up event should be triggered before the + * second delay() in the withTimeout() (which foundation's long press uses). Thus, the tap + * triggers and NOT the long press. + * + * Actual steps this test uses to recreate this scenario: + * 1. Down event is triggered + * 2. An up event is scheduled to be triggered BEFORE the timeout for a long press (uses + * a coroutine sleep() that is less than the long press timeout). + * 3. The UI thread sleeps before the sleep() awakens to fire the up event (the sleep time is + * LONGER than the long press timeout). + * 4. The UI thread wakes up, executes the first sleep() for the long press timeout + * (in withTimeout() implementation [`SuspendingPointerInputModifierNodeImpl`]) + * 5. The up event is fired (sleep() for test coroutine finishes). + * 6. Tap is triggered (that is, long press is NOT triggered because the second sleep() is + * NOT executed in withTimeout()). + */ @Test fun detectTapGestures_blockedMainThread() { var didLongPress = false var didTap = false - var inputLatch = CountDownLatch(1) + val positionedLatch = CountDownLatch(1) + var pressLatch = CountDownLatch(1) + var clickOrLongPressLatch = CountDownLatch(1) + + lateinit var coroutineScope: CoroutineScope + + val locationInWindow = IntArray(2) + + // Less than long press timeout + val touchUpDelay = 100 + // 400L + val longPressTimeout = android.view.ViewConfiguration.getLongPressTimeout() + // Goes past long press timeout (above) + val sleepTime = longPressTimeout + 100L + // matches first delay time in [PointerEventHandlerCoroutine.withTimeout()] + val withTimeoutDelay = longPressTimeout - WITH_TIMEOUT_MICRO_DELAY_MILLIS + var upEvent: MotionEvent? = null rule.runOnUiThread { container.setContent { + coroutineScope = rememberCoroutineScope() + FillLayout( Modifier.pointerInput(Unit) { detectTapGestures( onLongPress = { didLongPress = true - inputLatch.countDown() + clickOrLongPressLatch.countDown() }, onTap = { didTap = true - inputLatch.countDown() + clickOrLongPressLatch.countDown() + }, + onPress = { + // AwaitPointerEventScope.waitForLongPress() uses + // PointerEventHandlerCoroutine.withTimeout() as part of + // the timeout logic to see if a long press has occurred. + // + // Within PointerEventHandlerCoroutine.withTimeout(), there + // is a coroutine with two delay() calls and we are + // specifically testing that an up event that is put to + // sleep (but within the timeout time), does not trigger a + // long press when it comes in between those delay() calls. + // + // To do that, we want to get the timing of this coroutine + // as close to timeout as possible. That is, executing the + // up event (after the delay below) right between those + // delays to avoid the test being flaky. + coroutineScope.launch { + // Matches first delay used with withTimeout() for long + // press. + delay(withTimeoutDelay) + findRootView(container).dispatchTouchEvent(upEvent!!) + } + pressLatch.countDown() } ) } @@ -743,14 +809,8 @@ class AndroidPointerInputTest { } assertTrue(positionedLatch.await(1, TimeUnit.SECONDS)) - val locationInWindow = IntArray(2) container.getLocationInWindow(locationInWindow) - val handler = Handler(Looper.getMainLooper()) - - val touchUpDelay = 100 - val sleepTime = android.view.ViewConfiguration.getLongPressTimeout() + 100L - repeat(5) { iteration -> rule.runOnUiThread { val downEvent = @@ -759,33 +819,32 @@ class AndroidPointerInputTest { ACTION_DOWN, locationInWindow ) - findRootView(container).dispatchTouchEvent(downEvent) - } - rule.runOnUiThread { - val upEvent = + upEvent = createPointerEventAt( touchUpDelay + iteration * sleepTime.toInt(), ACTION_UP, locationInWindow ) - handler.postDelayed( - Runnable { findRootView(container).dispatchTouchEvent(upEvent) }, - touchUpDelay.toLong() - ) - - // Block the UI thread from now until past the long-press - // timeout. This tests that even in pathological situations, - // the upEvent is still processed before the long-press timeout. - Thread.sleep(sleepTime) + findRootView(container).dispatchTouchEvent(downEvent) } - assertTrue(inputLatch.await(1, TimeUnit.SECONDS)) + assertTrue(pressLatch.await(1, TimeUnit.SECONDS)) + + // Blocks the UI thread from now until past the long-press + // timeout. This tests that even in pathological situations, + // the upEvent is still processed before the long-press + // timeout. + rule.runOnUiThread { Thread.sleep(sleepTime) } + + assertTrue(clickOrLongPressLatch.await(1, TimeUnit.SECONDS)) + assertFalse(didLongPress) assertTrue(didTap) didTap = false - inputLatch = CountDownLatch(1) + clickOrLongPressLatch = CountDownLatch(1) + pressLatch = CountDownLatch(1) } } @@ -1534,12 +1593,9 @@ class AndroidPointerInputTest { * NOTE 2: The [MotionEvent.obtain()] that allows you to set classification, is only available * in U. (Thus, why this test request at least that version.) */ - @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @Test fun motionEventDispatch_withValidClassification_shouldMatchInPointerEvent() { - // Skips this test if the SDK is below Android U - assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - // --> Arrange var boxLayoutCoordinates: LayoutCoordinates? = null val setUpFinishedLatch = CountDownLatch(1) diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt new file mode 100644 index 0000000000000..43e2af761eaa1 --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt @@ -0,0 +1,176 @@ +/* + * 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. + */ + +package androidx.compose.ui.input.pointer + +import android.view.MotionEvent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.TouchInjectionScope +import androidx.compose.ui.test.invokeGlobalAssertions +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.core.view.InputDeviceCompat +import androidx.test.platform.app.InstrumentationRegistry +import kotlin.math.roundToInt + +// We don't have StylusInjectionScope at the moment. This is a simplified implementation for +// the basic use cases in tests. It only supports single stylus pointer, and the pointerId +// is totally ignored. +internal class HandwritingTestStylusInjectScope(semanticsNode: SemanticsNode) : + TouchInjectionScope, Density by semanticsNode.layoutInfo.density { + private val root = semanticsNode.root as ViewRootForTest + private val downTime: Long = System.currentTimeMillis() + + private var lastPosition: Offset = Offset.Unspecified + private var currentTime: Long = System.currentTimeMillis() + private val boundsInRoot = semanticsNode.boundsInRoot + + override val visibleSize: IntSize = + IntSize(boundsInRoot.width.roundToInt(), boundsInRoot.height.roundToInt()) + + override val viewConfiguration: ViewConfiguration = semanticsNode.layoutInfo.viewConfiguration + + private fun localToRoot(position: Offset): Offset { + return position + boundsInRoot.topLeft + } + + override fun advanceEventTime(durationMillis: Long) { + require(durationMillis >= 0) { + "duration of a delay can only be positive, not $durationMillis" + } + currentTime += durationMillis + } + + override fun currentPosition(pointerId: Int): Offset { + return lastPosition + } + + override fun down(pointerId: Int, position: Offset) { + lastPosition = localToRoot(position) + sendTouchEvent(MotionEvent.ACTION_DOWN) + } + + override fun updatePointerTo(pointerId: Int, position: Offset) { + lastPosition = localToRoot(position) + } + + override fun move(delayMillis: Long) { + advanceEventTime(delayMillis) + sendTouchEvent(MotionEvent.ACTION_MOVE) + } + + @ExperimentalTestApi + override fun moveWithHistoryMultiPointer( + relativeHistoricalTimes: List, + historicalCoordinates: List>, + delayMillis: Long + ) { + // Not needed for this test because Android only supports one stylus pointer. + } + + override fun up(pointerId: Int) { + sendTouchEvent(MotionEvent.ACTION_UP) + } + + fun hoverEnter(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) { + advanceEventTime(delayMillis) + lastPosition = localToRoot(position) + sendTouchEvent(MotionEvent.ACTION_HOVER_ENTER) + } + + fun hoverMoveTo(position: Offset, delayMillis: Long = eventPeriodMillis) { + advanceEventTime(delayMillis) + lastPosition = localToRoot(position) + sendTouchEvent(MotionEvent.ACTION_HOVER_MOVE) + } + + fun hoverExit(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) { + advanceEventTime(delayMillis) + lastPosition = localToRoot(position) + sendTouchEvent(MotionEvent.ACTION_HOVER_EXIT) + } + + override fun cancel(delayMillis: Long) { + sendTouchEvent(MotionEvent.ACTION_CANCEL) + } + + private fun sendTouchEvent(action: Int) { + val motionEvent = + MotionEvent.obtain( + /* downTime = */ downTime, + /* eventTime = */ currentTime, + /* action = */ action, + /* pointerCount = */ 1, + /* pointerProperties = */ arrayOf( + MotionEvent.PointerProperties().apply { + id = 0 + toolType = MotionEvent.TOOL_TYPE_STYLUS + } + ), + /* pointerCoords = */ arrayOf( + MotionEvent.PointerCoords().apply { + val startOffset = lastPosition + + // Allows for non-valid numbers/Offsets to be passed along to Compose to + // test if it handles them properly (versus breaking here and we not knowing + // if Compose properly handles these values). + x = + if (startOffset.isValid()) { + startOffset.x + } else { + Float.NaN + } + + y = + if (startOffset.isValid()) { + startOffset.y + } else { + Float.NaN + } + } + ), + /* metaState = */ 0, + /* buttonState = */ 0, + /* xPrecision = */ 1f, + /* yPrecision = */ 1f, + /* deviceId = */ 0, + /* edgeFlags = */ 0, + /* source = */ InputDeviceCompat.SOURCE_STYLUS, + /* flags = */ 0 + ) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + root.view.dispatchTouchEvent(motionEvent) + } + } +} + +internal fun SemanticsNodeInteraction.performStylusInput( + block: HandwritingTestStylusInjectScope.() -> Unit +): SemanticsNodeInteraction { + @OptIn(ExperimentalTestApi::class) invokeGlobalAssertions() + tryPerformAccessibilityChecks() + val node = fetchSemanticsNode("Failed to inject stylus input.") + val stylusInjectionScope = HandwritingTestStylusInjectScope(node) + block.invoke(stylusInjectionScope) + return this +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt index 2e76b6253971f..d2d8f53bb584d 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt @@ -20,11 +20,12 @@ package androidx.compose.ui.input.pointer import android.view.MotionEvent.ACTION_HOVER_ENTER import android.view.MotionEvent.ACTION_HOVER_EXIT +import androidx.collection.IntObjectMap import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusOwner @@ -61,6 +62,7 @@ import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -3383,7 +3385,7 @@ private class MockOwner( override val autofill: Autofill? get() = TODO("Not yet implemented") - override val semanticAutofill: SemanticAutofill? + override val autofillManager: AutofillManager get() = TODO("Not yet implemented") override val density: Density @@ -3415,6 +3417,9 @@ private class MockOwner( override val focusOwner: FocusOwner get() = TODO("Not yet implemented") + override val semanticsOwner: SemanticsOwner + get() = TODO("Not yet implemented") + override val windowInfo: WindowInfo get() = TODO("Not yet implemented") @@ -3560,8 +3565,12 @@ private class MockOwner( } override var measureIteration: Long = 0 + override val viewConfiguration: ViewConfiguration get() = TODO("Not yet implemented") + override val layoutNodes: IntObjectMap + get() = TODO("Not yet implemented") + override val sharedDrawScope = LayoutNodeDrawScope() } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt index 94a989653e44f..9352d33068c63 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt @@ -79,6 +79,12 @@ class PointerIconTest { override fun setIcon(value: PointerIcon?) { currentIcon = value ?: PointerIcon.Default } + + override fun getStylusHoverIcon(): PointerIcon? { + return null + } + + override fun setStylusHoverIcon(value: PointerIcon?) {} } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt index 0c03bf205fa15..cda56780e1a11 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt @@ -21,11 +21,13 @@ package androidx.compose.ui.input.pointer import android.view.InputDevice import android.view.KeyEvent as AndroidKeyEvent import android.view.MotionEvent +import androidx.collection.IntObjectMap +import androidx.collection.intObjectMapOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusOwner @@ -56,6 +58,8 @@ import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.semantics.EmptySemanticsModifier +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -2846,6 +2850,9 @@ private class TestOwner : Owner { override val rootForTest: RootForTest get() = TODO("Not yet implemented") + override val layoutNodes: IntObjectMap + get() = TODO("Not yet implemented") + override val hapticFeedBack: HapticFeedback get() = TODO("Not yet implemented") @@ -2873,7 +2880,7 @@ private class TestOwner : Owner { override val autofill: Autofill? get() = null - override val semanticAutofill: SemanticAutofill? + override val autofillManager: AutofillManager? get() = null override val density: Density @@ -2905,6 +2912,9 @@ private class TestOwner : Owner { override val focusOwner: FocusOwner get() = TODO("Not yet implemented") + override val semanticsOwner: SemanticsOwner = + SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf()) + override val windowInfo: WindowInfo get() = TODO("Not yet implemented") diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt new file mode 100644 index 0000000000000..398713c0babde --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt @@ -0,0 +1,3705 @@ +/* + * 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. + */ +package androidx.compose.ui.input.pointer + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.InspectableValue +import androidx.compose.ui.platform.LocalPointerIconService +import androidx.compose.ui.platform.isDebugInspectorInfoEnabled +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@MediumTest +@RunWith(AndroidJUnit4::class) +class StylusHoverIconTest { + @get:Rule val rule = createComposeRule() + private val parentIconTag = "myParentIcon" + private val childIconTag = "myChildIcon" + private val grandchildIconTag = "myGrandchildIcon" + private val desiredParentIcon = PointerIcon.Crosshair // AndroidPointerIcon(type=1007) + private val desiredChildIcon = PointerIcon.Text // AndroidPointerIcon(type=1008) + private val desiredGrandchildIcon = PointerIcon.Hand // AndroidPointerIcon(type=1002) + private lateinit var iconService: PointerIconService + + @Before + fun setup() { + iconService = + object : PointerIconService { + private var currentIcon: PointerIcon = PointerIcon.Default + private var currentStylusHoverIcon: PointerIcon? = null + + override fun getIcon(): PointerIcon { + return currentIcon + } + + override fun setIcon(value: PointerIcon?) { + currentIcon = value ?: PointerIcon.Default + } + + override fun getStylusHoverIcon(): PointerIcon? { + return currentStylusHoverIcon + } + + override fun setStylusHoverIcon(value: PointerIcon?) { + currentStylusHoverIcon = value + } + } + } + + @Test + fun testInspectorValue() { + isDebugInspectorInfoEnabled = true + rule.setContent { + val modifier = + Modifier.stylusHoverIcon(PointerIcon.Hand, overrideDescendants = false) + as InspectableValue + assertThat(modifier.nameFallback).isEqualTo("stylusHoverIcon") + assertThat(modifier.valueOverride).isNull() + assertThat(modifier.inspectableElements.map { it.name }.asIterable()) + .containsExactly( + "icon", + "overrideDescendants", + "touchBoundsExpansion", + ) + } + isDebugInspectorInfoEnabled = false + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Because we don't move the cursor, the icon will be null. We also want to + * check that when using a .stylusHoverIcon modifier with a composable, composition only happens + * once (per composable). + */ + @Test + fun parentChildFullOverlap_noOverrideDescendants_checkNumberOfCompositions() { + + var numberOfCompositions = 0 + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + numberOfCompositions++ + + Box( + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + numberOfCompositions++ + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isNull() + assertThat(numberOfCompositions).isEqualTo(2) + } + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire Box area because it’s + * lower in the hierarchy than Parent Box. If the Parent Box's overrideDescendants = false, the + * Child Box takes priority. + */ + @Test + fun parentChildFullOverlap_noOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify Parent Box is respecting Child Box's icon + verifyIconOnHover(parentIconTag, desiredChildIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because + * it’s higher in the hierarchy than Child Box. Also the Parent Box's overrideDescendants value + * is TRUE, so as the topmost parent in the hierarchy with overrideDescendants = true, all its + * children must respect it. + */ + @Test + fun parentChildFullOverlap_parentOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = TRUE) + * + * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire Box area because its + * lower in priority than Parent Box. If the Parent Box's overrideDescendants = false, the Child + * Box takes priority. + */ + @Test + fun parentChildFullOverlap_childOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify Parent Box is respecting Child Box's icon + verifyIconOnHover(parentIconTag, desiredChildIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = TRUE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because + * its overrideDescendants = true. The Parent Box takes precedence because it is the topmost + * parent in the hierarchy with overrideDescendants = true. + */ + @Test + fun parentChildFullOverlap_bothOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Child Box’s [PointerIcon.Hand] wins for the entire Child Box surface area + * because there's no parent in its hierarchy that has overrideDescendants = true. Parent Box's + * [PointerIcon.Crosshair] wins for all remaining surface area of its Box that doesn't overlap + * with Child Box. + */ + @Test + fun parentChildPartialOverlap_noOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify remaining Parent Box's area is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Parent Box’s [PointerIcon.Hand] wins for the entire Box area because its + * overrideDescendants = true, so every child underneath it in the hierarchy must respect its + * pointer icon since it's the topmost parent in the hierarchy with overrideDescendants = true. + */ + @Test + fun parentChildPartialOverlap_parentOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Child Box’s [PointerIcon.Hand] wins for the entire Child Box surface area + * because it’s lower in the hierarchy than Parent Box. If Parent Box's overrideDescendants = + * false, the Child Box takes priority. + */ + @Test + fun parentChildPartialOverlap_childOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify remaining Parent Box's area is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because + * its overrideDescendants = true. If multiple locations in the hierarchy set + * overrideDescendants = true, the highest parent in the hierarchy takes precedence (in this + * example, it was Parent Box). + */ + @Test + fun parentChildPartialOverlap_bothOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (no custom icon) ⤷ + * Child Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire + * surface area because it has no competition in the hierarchy for any other custom icons. After + * the Parent Box dynamically has the stylusHoverIcon Modifier added to it, the Parent Box's + * [PointerIcon.Crosshair] should win for the entire surface area of the Parent Box and Child + * Box because the Parent Box has overrideDescendants = true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) + */ + @Test + fun parentChildPartialOverlap_parentModifierDynamicallyAdded() { + val isVisible = mutableStateOf(false) + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .then( + if (isVisible.value) + Modifier.stylusHoverIcon( + desiredParentIcon, + overrideDescendants = true + ) + else Modifier + ) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify Parent Box's icon is the desired default icon + verifyIconOnHover(parentIconTag, null) + // Dynamically add the stylusHoverIcon Modifier to the Parent Box + rule.runOnIdle { isVisible.value = true } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (no custom icon) ⤷ + * Child Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire + * surface area because it has no competition in the hierarchy for any other custom icons. After + * the Parent Box dynamically has the stylusHoverIcon Modifier added to it, the Parent Box's + * [PointerIcon.Crosshair] should win for the entire surface area of the Parent Box and Child + * Box because the Parent Box has overrideDescendants = true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) + */ + @Ignore("b/299482894 - not yet implemented") + @Test + fun parentChildPartialOverlap_parentModifierDynamicallyAddedWithMoveEvents() { + val isVisible = mutableStateOf(false) + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .then( + if (isVisible.value) + Modifier.stylusHoverIcon( + desiredParentIcon, + overrideDescendants = true + ) + else Modifier + ) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over Child Box and verify it has the desired child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move to Parent Box and verify its icon is the desired default icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Move back to the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically add the stylusHoverIcon Modifier to the Parent Box + rule.runOnIdle { isVisible.value = true } + // Verify the Child Box has updated to respect the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move within the Child Box and verify it is still respecting the desired parent icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + // Move to the Parent Box and verify it also has the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * After several assertions, it reverts back to false in the parent: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire + * surface area because the parent does not override descendants. After the Parent Box + * dynamically changes overrideDescendants to true, the Parent Box's [PointerIcon.Crosshair] + * should win for the entire surface area of the Parent Box and Child Box because the Parent Box + * has overrideDescendants = true. + * + * It should then revert back to Child Box's [PointerIcon.Text] after the Parent Box's + * overrideDescendants is set back to false. + */ + @Test + fun parentChildPartialOverlap_parentModifierDynamicallyChangedToOverrideWithMoveEvents() { + var parentOverrideDescendants by mutableStateOf(false) + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .then( + Modifier.stylusHoverIcon( + desiredParentIcon, + overrideDescendants = parentOverrideDescendants + ) + ) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over Child Box and verify it has the desired child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move to Parent Box and verify its icon is the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move back to the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Dynamically change the stylusHoverIcon Modifier to the Parent Box to + // override descendants. + rule.runOnIdle { parentOverrideDescendants = true } + + // Verify the Child Box has updated to respect the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + + // Move within the Child Box and verify it is still respecting the desired parent icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + + // Verify the Child Box has updated to respect the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + + // Move to the Parent Box and verify it also has the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + + // Move within the Child Box and verify it is still respecting the desired parent icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + + // Dynamically change the stylusHoverIcon Modifier to the Parent Box to NOT + // override descendants. + rule.runOnIdle { parentOverrideDescendants = false } + + // Verify it's changed to child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Move to Parent Box and verify its icon is the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move back to the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Initially, Child Box’s [PointerIcon.Hand] wins for the entire Child Box + * surface area because there's no parent in its hierarchy that has overrideDescendants = true. + * Additionally, Parent Box's [PointerIcon.Crosshair] would initially win for all remaining + * surface area of its Box that doesn't overlap with Child Box. Once Parent Box's + * overrideDescendants parameter is dynamically updated to true, the Parent Box's icon should + * win for its entire surface area, including within Child Box. + */ + @Test + fun parentChildPartialOverlap_parentOverrideDescendantsDynamicallyUpdated() { + val parentOverrideState = mutableStateOf(false) + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon( + desiredParentIcon, + overrideDescendants = parentOverrideState.value + ) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify remaining Parent Box's area is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + rule.runOnIdle { parentOverrideState.value = true } + // Verify Child Box's icon is the desired parent icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify Parent Box also has the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over various parts of the screen and verify the results, we update the + * parent's overrideDescendants to true: Parent Box (custom icon = [PointerIcon.Crosshair], + * overrideDescendants = TRUE) ⤷ Child Box (custom icon = [PointerIcon.Text], + * overrideDescendants = FALSE) + * + * After several assertions, it reverts back to false in the parent: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire + * surface area because the parent does not override descendants. After the Parent Box + * dynamically changes overrideDescendants to true, the Parent Box's [PointerIcon.Crosshair] + * should win for the child's surface area within the Parent Box BUT NOT the portion of the + * Child Box that is outside the Parent Box. + * + * It should then revert back to Child Box's [PointerIcon.Text] (in all scenarios) after the + * Parent Box's overrideDescendants is set back to false. + */ + @Test + fun parentChildPartialOverlapAndExtendsBeyondParent_dynamicOverrideDescendants() { + var parentOverrideDescendants by mutableStateOf(false) + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(300.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Green))) + ) { + // This child extends beyond the borders of the parent (enabling this test) + Box( + modifier = + Modifier.size(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .then( + Modifier.stylusHoverIcon( + desiredParentIcon, + overrideDescendants = parentOverrideDescendants + ) + ) + ) { + Box( + Modifier.padding(20.dp) + .offset(100.dp) + .width(300.dp) + .height(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over Child Box and verify it has the desired child icon (outside parent) + rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Hover over Child Box and verify it has the desired child icon (inside parent) + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Move to Parent Box and verify its icon is the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move back to the Child Box (portion inside parent) + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Dynamically change the stylusHoverIcon Modifier of the Parent Box to + // override descendants. + rule.runOnIdle { parentOverrideDescendants = true } + + // Verify the Child Box has updated to respect the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + + // Hover over Child Box and verify it has the desired child icon (outside parent) + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Move to the Parent Box and verify it also has the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + + // Move within the Child Box (portion inside parent) and verify it is still + // respecting the desired parent icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + + // Dynamically change the stylusHoverIcon Modifier of the Parent Box to NOT + // override descendants. + rule.runOnIdle { parentOverrideDescendants = false } + + // Verify it's changed to child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Move to Parent Box and verify its icon is the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move back to the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's + * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No + * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA + * Box or ChildB Box. In this example, there's no competition for pointer icons because the + * parent has no icon set and neither ChildA or ChildB Boxes overlap. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun NonOverlappingSiblings_noOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Column { + Box( + Modifier.padding(20.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(40.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify ChildA Box's icon is the desired ChildA icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify ChildB Box's icon is the desired ChildB icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Parent Box's icon is the default icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's + * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No + * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA + * Box or ChildB Box. In this example, it doesn't matter whether ChildA Box's + * overrideDescendants = true or false because there's no competition for pointer icons in this + * example. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun NonOverlappingSiblings_firstChildOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Column { + Box( + Modifier.padding(20.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(40.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify ChildA Box's icon is the desired ChildA icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify ChildB Box's icon is the desired ChildB icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Parent Box's icon is the default icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's + * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No + * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA + * Box or ChildB Box. In this example, it doesn't matter whether ChildB Box's + * overrideDescendants = true or false because there's no competition for pointer icons in this + * example. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun NonOverlappingSiblings_secondChildOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Column { + Box( + Modifier.padding(20.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(40.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify ChildA Box's icon is the desired ChildA icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify ChildB Box's icon is the desired ChildB icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Parent Box's icon is the default icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's + * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No + * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA + * Box or ChildB Box. In this example, it doesn't matter whether ChildA Box and ChildB Box's + * overrideDescendants = true or false because there's no competition for pointer icons in this + * example. + * + * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun NonOverlappingSiblings_bothOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Column { + Box( + Modifier.padding(20.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(40.dp) + .requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify ChildA Box's icon is the desired ChildA icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify ChildB Box's icon is the desired ChildB icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Parent Box's icon is the default icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) where ChildB Box's surface area overlaps + * with its sibling, ChildA, within the Parent Box + * + * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's + * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not + * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box + * that's not covered by ChildA Box or ChildB Box. + * + * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun OverlappingSiblings_noOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(120.dp, 60.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(horizontal = 100.dp, vertical = 40.dp) + .requiredSize(120.dp, 20.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + + verifyOverlappingSiblings() + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) where ChildB Box's surface area overlaps + * with its sibling, ChildA, within the Parent Box + * + * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's + * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not + * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box + * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects + * that element's children. So in this example, it doesn't matter whether ChildA Box's + * overrideDescendants = true because ChildB is its sibling and is therefore unaffected by this + * param. + * + * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun OverlappingSiblings_childAOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(120.dp, 60.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(horizontal = 100.dp, vertical = 40.dp) + .requiredSize(120.dp, 20.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + + verifyOverlappingSiblings() + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with + * its sibling, ChildA, within the Parent Box + * + * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's + * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not + * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box + * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects + * that element's children. So in this example, it doesn't matter whether ChildB Box's + * overrideDescendants = true because ChildA is its sibling and is therefore unaffected by this + * param. + * + * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun OverlappingSiblings_childBOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(120.dp, 60.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(horizontal = 100.dp, vertical = 40.dp) + .requiredSize(120.dp, 20.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + + verifyOverlappingSiblings() + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with + * its sibling, ChildA, within the Parent Box + * + * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's + * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not + * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box + * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects + * that element's children. So in this example, it doesn't matter whether ChildA Box or ChildB + * Box's overrideDescendants = true because ChildA and ChildB Boxes are siblings and are + * unaffected by each other's overrideDescendants param. + * + * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun OverlappingSiblings_bothOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(120.dp, 60.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(horizontal = 100.dp, vertical = 40.dp) + .requiredSize(120.dp, 20.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + + verifyOverlappingSiblings() + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ ChildA Box (custom icon = + * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with + * its sibling, ChildA, within the Parent Box + * + * Expected Output: Parent Box's [PointerIcon.Crosshair] wins for the entire surface area of its + * box, including the surface area within ChildA Box and ChildB Box. Parent Box has + * overrideDescendants = true, which takes priority over any custom icon set by its children. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ ChildA Box (output icon = + * [PointerIcon.Crosshair]) ⤷ ChildB Box (output icon = [PointerIcon.Crosshair]) + */ + @Test + fun OverlappingSiblings_parentOverridesDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(120.dp, 60.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.padding(horizontal = 100.dp, vertical = 40.dp) + .requiredSize(120.dp, 20.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over ChildB (bottom right corner) and verify desired Parent icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Then hover to parent (bottom right corner) and icon hasn't changed + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Then hover to ChildA (bottom left corner) and verify icon hasn't changed + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box + * (no custom icon set) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand], overrideDescendants + * = FALSE) + * + * Expected Output: Grandchild Box’s [PointerIcon.Hand] wins for the entire surface area of + * Grandchild's Box. No icon wins for the remainder of the surface area of Parent Box that isn't + * covered by Grandchild Box. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = null) ⤷ Grandchild Box (output + * icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_grandchildCustomIconNoOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box's area is the default arrow icon + verifyIconOnHover(childIconTag, null) + // Verify remaining Parent Box's area is the default arrow icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box + * (no custom icon set) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand], overrideDescendants + * = TRUE) + * + * Expected Output: Grandchild Box’s [PointerIcon.Hand] wins for the entire surface area of + * Grandchild's Box. No icon wins for the remainder of the surface area of Parent Box that isn't + * covered by Grandchild Box. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = null) ⤷ Grandchild Box (output + * icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_grandchildCustomIconHasOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box's area is the default arrow icon + verifyIconOnHover(childIconTag, null) + // Verify remaining Parent Box's area is the default arrow icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the + * Grandchild Box. Child Box’s [PointerIcon.Text] wins for remaining surface area of its Box not + * covered by the Grandchild Box. No icon wins for the remainder of the surface area of Parent + * Box that isn't covered by Child Box. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild + * Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_childAndGrandchildCustomIconsNoOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify remaining Parent Box's area is the default arrow icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the + * Grandchild Box. Child Box’s [PointerIcon.Text] wins for the remainder of the Child Box's + * surface area that's not covered by the Grandchild box. No icon wins for the remainder of the + * surface area of Parent Box that isn't covered by Child Box. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild + * Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_childCustomIconGrandchildHasOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify remaining Parent Box's area is the default arrow icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire surface area of its Box + * (including all of the Grandchild Box since it is contained within Child Box's surface area). + * No icon wins for the remainder of the surface area of Parent Box that isn't covered by Child + * Box. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild + * Box (output icon = [PointerIcon.Text]) + */ + @Test + fun multiLayeredNesting_grandchildCustomIconChildHasOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify Grandchild Box is respecting Child Box's icon + verifyIconOnHover(grandchildIconTag, desiredChildIcon) + // Verify remaining Parent Box's area is the default arrow icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire surface area of its Box + * (including all of the Grandchild Box since it is contained within Child Box's surface area). + * No icon wins for the remainder of the surface area of Parent Box that isn't covered by Child + * Box. + * + * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild + * Box (output icon = [PointerIcon.Text]) + */ + @Test + fun multiLayeredNesting_childAndGrandchildOverrideDescendants() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify Grandchild Box is respecting Child Box's icon + verifyIconOnHover(grandchildIconTag, desiredChildIcon) + // Verify remaining Parent Box's area is the default arrow icon + verifyIconOnHover(parentIconTag, null) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (no icon set) ⤷ Grandchild + * Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the + * Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of + * the Pare Box that's not covered by the Grandchild Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_parentAndGrandchildCustomIconNoOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify remaining Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (no icon set) ⤷ Grandchild + * Box (custom icon = [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of its + * Box. Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of the Pare Box + * that's not covered by the Grandchild Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_parentCustomIconGrandchildOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify remaining Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the + * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the + * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for + * the remaining surface area not covered by the Child Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_allCustomIconsNoOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify remaining Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the + * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the + * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for + * the remaining surface area not covered by the Child Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun multiLayeredNesting_allCustomIconsGrandchildOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Grandchild Box's icon is the desired grandchild icon + verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon) + // Verify remaining Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify remaining Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Child Box's [PointerIcon.Hand] wins for the entire surface area of its Box. + * Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of its Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Text]) + */ + @Test + fun multiLayeredNesting_allCustomIconsChildOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify Grandchild Box is respecting Child Box's icon + verifyIconOnHover(grandchildIconTag, desiredChildIcon) + // Verify remaining Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Child Box's [PointerIcon.Hand] wins for the entire surface area of its Box. + * Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of its Box. The + * addition of Grandchild Box’s overrideDescendants = true in this test doesn’t impact the + * outcome; this is because Child Box is Grandchild Box's parent in the hierarchy and it already + * has overrideDescendants = true, which takes priority over anything Grandchild Box sets. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Text]) + */ + @Test + fun multiLayeredNesting_allCustomIconsChildAndGrandchildOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Child Box's icon is the desired child icon + verifyIconOnHover(childIconTag, desiredChildIcon) + // Verify Grandchild Box is respecting Child Box's icon + verifyIconOnHover(grandchildIconTag, desiredChildIcon) + // Verify remaining Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (no icon set) ⤷ Grandchild + * Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its + * Box. Even though the Grandchild Box’s icon was set, the Parent Box will always take priority + * because it's the highestmost level in the hierarchy where overrideDescendants = true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair]) + */ + @Test + fun multiLayeredNesting_parentGrandChildCustomIconsParentOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify Grandchild Box is respecting Parent Box's icon + verifyIconOnHover(grandchildIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (no icon set) ⤷ Grandchild + * Box (custom icon = [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its + * Box. Even though the Grandchild Box’s icon was set, the Parent Box will always take priority + * because it's the highestmost level in the hierarchy where overrideDescendants = true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair]) + */ + @Test + fun multiLayeredNesting_parentGrandChildCustomIconsBothOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify Grandchild Box is respecting Parent Box's icon + verifyIconOnHover(grandchildIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its + * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always + * take priority because it's the highestmost level in the hierarchy where overrideDescendants = + * true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair]) + */ + @Test + fun multiLayeredNesting_allCustomIconsParentOverrides() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify Grandchild Box is respecting Parent Box's icon + verifyIconOnHover(grandchildIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its + * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always + * take priority because it's the highestmost level in the hierarchy where overrideDescendants = + * true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair]) + */ + @Test + fun multiLayeredNesting_allCustomIconsParentAndGrandchildOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify Grandchild Box is respecting Parent Box's icon + verifyIconOnHover(grandchildIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its + * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always + * take priority because it's the highestmost level in the hierarchy where overrideDescendants = + * true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair]) + */ + @Test + fun multiLayeredNesting_allCustomIconsParentAndChildOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify Grandchild Box is respecting Parent Box's icon + verifyIconOnHover(grandchildIconTag, desiredParentIcon) + } + + /** + * Setup: The hierarchy for this test is setup as: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its + * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always + * take priority because it's the highestmost level in the hierarchy where overrideDescendants = + * true. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair]) + */ + @Test + fun multiLayeredNesting_allIconsOverride() { + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true) + ) { + Box( + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true) + ) + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Verify Parent Box's icon is the desired parent icon + verifyIconOnHover(parentIconTag, desiredParentIcon) + // Verify Child Box is respecting Parent Box's icon + verifyIconOnHover(childIconTag, desiredParentIcon) + // Verify Grandchild Box is respecting Parent Box's icon + verifyIconOnHover(grandchildIconTag, desiredParentIcon) + } + + /** + * This test takes an existing Box with a custom icon and changes the custom icon to a different + * custom icon while the cursor is hovered over the box. + */ + @Test + fun dynamicallyUpdatedIcon() { + val icon = mutableStateOf(desiredChildIcon) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.padding(20.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(icon.value, overrideDescendants = false) + ) + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) } + // Verify Child Box has the desired child icon and dynamically update the icon assigned to + // the Child Box while hovering over Child Box + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) + icon.value = desiredGrandchildIcon + } + // Verify the icon has been updated to the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor within Child Box and verify it still has the updated icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Exit hovering over Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its + * entire surface area because it has no competition in the hierarchy for any other custom + * icons. After the Child Box is dynamically added under the cursor, the Child Box's + * [PointerIcon.Text] should win for the entire surface area of the Child Box. This also + * requires updating the user facing cursor icon to reflect the Child Box that was added under + * the cursor. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) + */ + @Test + fun dynamicallyAddAndRemoveChild_noOverrideDescendants() { + val isChildVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (isChildVisible.value) { + Box( + modifier = + Modifier.padding(20.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) } + // Verify Parent Box has the desired parent icon and dynamically add the Child Box under the + // cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) + isChildVisible.value = true + } + // Verify the icon has been updated to the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor within Child Box and verify it still has the updated icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Child Box + rule.runOnIdle { isChildVisible.value = false } + // Verify the icon has been updated to the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = TRUE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: The Parent Box's [PointerIcon.Crosshair] should win for its entire surface + * area regardless of whether the Child Box is visible or not. This is because the Parent Box's + * overrideDescendants = true, so its children should always respect Parent Box's custom icon. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) + */ + @Test + fun dynamicallyAddAndRemoveChild_parentOverridesDescendants() { + val isChildVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true), + contentAlignment = Alignment.Center + ) { + if (isChildVisible.value) { + Box( + modifier = + Modifier.padding(20.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) } + // Verify Parent Box has the desired parent icon and dynamically add the Child Box under the + // cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) + isChildVisible.value = true + } + // Verify the icon stays as the parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor within Child Box and verify it still is the parent icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Child Box + rule.runOnIdle { isChildVisible.value = false } + // Verify the icon still the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its + * entire surface area because it has no competition in the hierarchy for any other custom + * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the + * Grandchild Box's [PointerIcon.Hand] should win for the entire surface area of the Grandchild + * Box. The Child Box's [PointerIcon.Text] should win for the remaining surface area of the + * Child Box not covered by the Grandchild Box. This also requires updating the user facing + * cursor icon to reflect the Child Box and Grandchild Box that were added under the cursor. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun dynamicallyAddAndRemoveChildAndGrandchild_noOverrideDescendants() { + val areDescendantsVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (areDescendantsVisible.value) { + Box( + modifier = + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon( + desiredGrandchildIcon, + overrideDescendants = false + ) + ) + } + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) } + // Verify Parent Box has the desired parent icon and dynamically add the Child Box and + // Grandchild Box under the cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) + areDescendantsVisible.value = true + } + // Verify the icon has been updated to the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor within Grandchild Box and verify it still has the grandchild icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor outside Grandchild Box within Child Box and verify it has the child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Child Box and Grandchild Box + rule.runOnIdle { areDescendantsVisible.value = false } + // Verify the icon has been updated to the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = TRUE) + * + * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its + * entire surface area because it has no competition in the hierarchy for any other custom + * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the + * Grandchild Box's [PointerIcon.Hand] should win for the entire surface area of the Grandchild + * Box. Because the Grandchild Box is the lowest level in the hierarchy, the outcome doesn't + * change whether it has overrideDescendants = true or not. The Child Box's [PointerIcon.Text] + * should win for the remaining surface area of the Child Box not covered by the Grandchild Box. + * This also requires updating the user facing cursor icon to reflect the Child Box and + * Grandchild Box that were added under the cursor. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun dynamicallyAddAndRemoveChildAndGrandchild_grandchildOverridesDescendants() { + val areDescendantsVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (areDescendantsVisible.value) { + Box( + modifier = + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon( + desiredGrandchildIcon, + overrideDescendants = true + ) + ) + } + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) } + // Verify Parent Box has the desired parent icon and dynamically add the Child Box and + // Grandchild Box under the cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) + areDescendantsVisible.value = true + } + // Verify the icon has been updated to the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor within Grandchild Box and verify it still has the grandchild icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor outside Grandchild Box within Child Box and verify it has the child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Child Box and Grandchild Box + rule.runOnIdle { areDescendantsVisible.value = false } + // Verify the icon has been updated to the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its + * entire surface area because it has no competition in the hierarchy for any other custom + * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the + * Child Box's [PointerIcon.Text] should win for the entire surface area of the Child Box. This + * includes the Grandchild Box's [PointerIcon.Text] should win for the remaining surface area of + * the Child Box not covered by the Grandchild Box. This also requires updating the user facing + * cursor icon to reflect the Child Box and Grandchild Box that were added under the cursor. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun dynamicallyAddAndRemoveChildAndGrandchild_childOverridesDescendants() { + val areDescendantsVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (areDescendantsVisible.value) { + Box( + modifier = + Modifier.padding(20.dp) + .requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier.padding(40.dp) + .requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon( + desiredGrandchildIcon, + overrideDescendants = false + ) + ) + } + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) } + // Verify Parent Box has the desired parent icon, then dynamically add the Child Box and + // Grandchild Box under the cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) + areDescendantsVisible.value = true + } + // Verify the icon has been updated to the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor within Grandchild Box and verify it still has the child icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Grandchild Box within Child Box to verify it still has the child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Child Box and Grandchild Box + rule.runOnIdle { areDescendantsVisible.value = false } + // Verify the icon has been updated to the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: The Child Box's [PointerIcon.Text] should win for its entire surface area + * regardless of whether there's a Parent Box present or not. This is because the Parent Box has + * overrideDescendants = false and should therefore not have its custom icon take priority over + * the Child Box's custom icon. The Parent Box's [PointerIcon.Crosshair] should win for its + * remaining surface area not covered by the Child Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) + */ + @Test + fun dynamicallyAddAndRemoveParent_noOverrideDescendants() { + val isParentVisible = mutableStateOf(false) + val child = movableContentOf { + Box( + modifier = + Modifier.requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + if (isParentVisible.value) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + child() + } + } else { + child() + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) } + // Verify Child Box has the desired child icon and dynamically add the Parent Box under the + // cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) + isParentVisible.value = true + } + // Verify the icon stays as the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor within Child Box and verify it still has the child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Parent Box + rule.runOnIdle { isParentVisible.value = false } + // Verify the icon stays as the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Exit hovering over Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) + * + * Expected Output: The Child Box's [PointerIcon.Text] should win for its entire surface area + * when the Parent Box isn't present. Once the Parent Box becomes visible, the Parent Box's + * [PointerIcon.Crosshair] should win for its entire surface area. This is because the Parent + * Box's overrideDescendants = true, so its children should always respect Parent Box's custom + * icon. This also requires updating the user facing cursor icon to reflect the Parent Box that + * was added under the cursor. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Crosshair]) + */ + @Test + fun dynamicallyAddAndRemoveParent_parentOverridesDescendants() { + val isParentVisible = mutableStateOf(false) + val child = movableContentOf { + Box( + modifier = + Modifier.requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + } + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + if (isParentVisible.value) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = true), + contentAlignment = Alignment.Center + ) { + child() + } + } else { + child() + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) } + // Verify Child Box has the desired child icon and dynamically add the Parent Box under the + // cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) + isParentVisible.value = true + } + // Verify the icon has been updated to the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor within Child Box and verify it still has the parent icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor outside Child Box and verify the icon is still the parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Parent Box + rule.runOnIdle { isParentVisible.value = false } + // Verify the icon has been updated to the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Exit hovering over Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface + * area regardless of whether there's a Child Box or Parent Box present. This is because the + * Parent Box and Child Box have overrideDescendants = false and should therefore not have their + * custom icons take priority over the Grandchild Box's custom icon. The Child Box should win + * for its remaining surface area not covered by the Grandchild Box. The Parent Box's + * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child + * Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun dynamicallyAddAndRemoveNestedChild_noOverrideDescendants() { + val isChildVisible = mutableStateOf(false) + val grandchild = movableContentOf { + Box( + modifier = + Modifier.requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (isChildVisible.value) { + Box( + modifier = + Modifier.requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + grandchild() + } + } else { + grandchild() + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) } + // Verify Grandchild Box has the desired grandchild icon and dynamically add the Child Box + // under the cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + isChildVisible.value = true + } + // Verify the icon stays as the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor within Grandchild Box and verify it still has the grandchild icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor outside Grandchild Box within Child Box to verify icon is now the child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to the center of the Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Child Box + rule.runOnIdle { isChildVisible.value = false } + // Verify the icon has been updated to the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * After hovering over the center of the screen, the hierarchy under the cursor updates to: + * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box + * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon + * = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface + * area regardless of whether there's a Child Box or Parent Box present. This is because the + * Parent Box and Child Box have overrideDescendants = false and should therefore not have thei + * custom icona take priority over the Grandchild Box's custom icon. The Child Box should win + * for its remaining surface area not covered by the Grandchild Box. The Parent Box's + * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child + * Box. Initially, the Parent Box's [PointerIcon.Crosshair] should win for its entire surface + * area because it has no competition in the hierarchy for any other custom icons. After the + * Child Box and the Grandchild Box are dynamically added under the cursor, the Child Box's + * [PointerIcon.Text] should win for the entire surface area of the Child Box. This includes the + * Grandchild Box's [PointerIcon.Text] should win for the remaining surface area of the Child + * Box not covered by the Grandchild Box. This also requires updating the user facing cursor + * icon to reflect the Child Box and Grandchild Box that were added under the cursor. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun dynamicallyAddAndRemoveNestedChild_ChildOverridesDescendants() { + val isChildVisible = mutableStateOf(false) + val grandchild = movableContentOf { + Box( + modifier = + Modifier.requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (isChildVisible.value) { + Box( + modifier = + Modifier.requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = true), + contentAlignment = Alignment.Center + ) { + grandchild() + } + } else { + grandchild() + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) } + // Verify Grandchild Box has the desired grandchild icon and dynamically add the Child Box + // under the cursor + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + isChildVisible.value = true + } + // Verify the icon has been updated to the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor within Grandchild Box and verify it still has the child icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Grandchild Box within Child Box to verify it still has the child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor outside Child Box and verify the icon is updated to the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to center of the Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the Child Box + rule.runOnIdle { isChildVisible.value = false } + // Verify the icon has been updated to the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Grandparent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * After hovering over the corner of the Grandparent Box that doesn't overlap with any + * descendant, the hierarchy of the screen updates to: Grandparent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface + * area regardless of whether there's a Child, Parent, or Grandparent Box present. This is + * because the Grandparent, Parent, and Child Boxes have overrideDescendants = false and should + * therefore not have their custom icons take priority over the Grandchild Box's custom icon. + * The Child Box should win for its remaining surface area not covered by the Grandchild Box. + * The Parent Box's [PointerIcon.Crosshair] should win for its remaining surface area not + * covered by the Child Box. And the Grandparent Box should win for its remaining surface area + * not covered by the Parent Box. + * + * Grandparent Box (output icon = [PointerIcon.Crosshair]) ⤷ Parent Box (output icon = + * [PointerIcon.Crosshair]) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild Box + * (output icon = [PointerIcon.Hand]) + */ + @Test + fun dynamicallyAddAndRemoveNestedChild_notHoveredOverChild() { + val grandparentIconTag = "myGrandparentIcon" + val desiredGrandparentIcon = desiredParentIcon + val isChildVisible = mutableStateOf(false) + val grandchild = movableContentOf { + Box( + modifier = + Modifier.requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false) + ) + } + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(grandparentIconTag) + .stylusHoverIcon(desiredGrandparentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier.requiredSize(175.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (isChildVisible.value) { + Box( + modifier = + Modifier.requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon( + desiredChildIcon, + overrideDescendants = false + ), + contentAlignment = Alignment.Center + ) { + grandchild() + } + } else { + grandchild() + } + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Grandchild Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) } + // Verify Grandchild Box has the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move to corner of Grandparent Box where no descendants are under the cursor + rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + // Verify the icon is the desired grandparent icon and dynamically add the Child Box + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon) + isChildVisible.value = true + } + // Verify the icon stays as the desired grandparent icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon) + } + // Move cursor within Grandparent Box and verify it still has the grandparent icon + rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon) + } + // Move cursor outside Grandparent Box to Parent Box to verify icon is now the parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor back to corner of Grandparent Box where no descendants are under the cursor + rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + // Dynamically remove the Child Box + rule.runOnIdle { isChildVisible.value = false } + // Verify the icon stays as the grandparent icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon) + } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the corner of the Parent Box that doesn't overlap with any descendant, + * the hierarchy of the screen updates to: Parent Box (custom icon = [PointerIcon.Crosshair], + * overrideDescendants = FALSE) ⤷ Child Box (custom icon = [PointerIcon.Text], + * overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand], + * overrideDescendants = FALSE) + * + * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface + * area regardless of whether there's a Child or Parent Box present. This is because the Parent + * and Child Boxes have overrideDescendants = false and should therefore not have their custom + * icons take priority over the Grandchild Box's custom icon. The Child Box should win for its + * remaining surface area not covered by the Grandchild Box. The Parent Box's + * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child + * Box. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun dynamicallyAddAndRemoveGrandchild_notHoveredOverGrandchild() { + val isGrandchildVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier.requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false), + contentAlignment = Alignment.Center + ) { + if (isGrandchildVisible.value) { + Box( + modifier = + Modifier.requiredSize(100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon( + desiredGrandchildIcon, + overrideDescendants = false + ) + ) + } + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over center of Child Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) } + // Verify Child Box has the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move to corner of Parent Box where no descendants are under the cursor + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + // Verify the icon is the desired parent icon and dynamically add the Grandchild Box + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) + isGrandchildVisible.value = true + } + // Verify the icon stays as the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor within Parent Box and verify it still has the grandparent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move cursor outside Parent Box to Child Box to verify icon is now the child icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor back to corner of Parent Box where no descendants are under the cursor + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + // Dynamically remove the Grandchild Box + rule.runOnIdle { isGrandchildVisible.value = false } + // Verify the icon stays as the parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ ChildA Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over the area where ChildB will be, the hierarchy of the screen updates to: + * Parent Box (no custom icon set) ⤷ ChildA Box (custom icon = [PointerIcon.Text], + * overrideDescendants = FALSE) ⤷ ChildB Box (custom icon = [PointerIcon.Hand], + * overrideDescendants = FALSE) + * + * Expected Output: Regardless of the presence of ChildB Box, ChildA Box's [PointerIcon.Text] + * should win for its entire surface area. Once ChildB Box appears, ChildB Box's + * [PointerIcon.Hand] should win for its entire surface area. Initially, Parent Box's + * [PointerIcon.Crosshair] should win for its entire surface area not covered by ChildA Box. + * Once ChildA Box appears, Parent Box should win for its entire surface not covered by either + * ChildA or ChildB Boxes. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Child Box (output icon = [PointerIcon.Hand]) + */ + @Ignore("b/271277248 - Remove Ignore annotation once input event bug is fixed") + @Test + fun dynamicallyAddAndRemoveSibling_hoveredOverAppearingSibling() { + val isChildBVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Column { + Box( + Modifier.requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + if (isChildBVisible.value) { + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.requiredSize(50.dp) + .offset(y = 100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(grandchildIconTag) + .stylusHoverIcon( + desiredGrandchildIcon, + overrideDescendants = false + ) + ) + } + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over corner of Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(bottomRight) } + // Verify Parent Box has the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move to center of ChildA Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Verify ChildA Box has the desired child icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move to left corner of Parent Box where ChildB will be added + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + // Dynamically add the ChildB Box + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) + isChildBVisible.value = true + } + // Verify the icon is updated to the desired ChildB icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move to corner of ChildB Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) } + // Verify ChildB Box has the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor back to the center of ChildA Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Verify that icon is updated to the desired ChildA icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor back to the location of ChildB + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the ChildB Box + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + isChildBVisible.value = false + } + // Verify the icon updates to the parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Exit hovering over ChildA Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon = + * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ ChildA Box (custom icon = + * [PointerIcon.Text], overrideDescendants = FALSE) + * + * After hovering over ChildA, the hierarchy of the screen updates to: Parent Box (no custom + * icon set) ⤷ ChildA Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ + * ChildB Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Regardless of the presence of ChildB Box, ChildA Box's [PointerIcon.Text] + * should win for its entire surface area. Once ChildB Box appears, ChildB Box's + * [PointerIcon.Hand] should win for its entire surface area. Initially, Parent Box's + * [PointerIcon.Crosshair] should win for its entire surface area not covered by ChildA Box. + * Once ChildA Box appears, Parent Box should win for its entire surface not covered by either + * ChildA or ChildB Boxes. + * + * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon = + * [PointerIcon.Text]) ⤷ Child Box (output icon = [PointerIcon.Hand]) + */ + @Ignore("b/271277248 - Remove Ignore annotation once input event bug is fixed") + @Test + fun dynamicallyAddAndRemoveSibling_notHoveredOverAppearingSibling() { + val isChildBVisible = mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.requiredSize(200.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Column { + Box( + Modifier.requiredSize(50.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) + if (isChildBVisible.value) { + // Referencing grandchild tag/icon for ChildB in this test + Box( + Modifier.requiredSize(50.dp) + .offset(y = 100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(grandchildIconTag) + .stylusHoverIcon( + desiredGrandchildIcon, + overrideDescendants = false + ) + ) + } + } + } + } + } + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over corner of Parent Box + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(bottomRight) } + // Verify Parent Box has the desired parent icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + // Move to center of ChildA Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Verify ChildA Box has the desired child icon and dynamically add the ChildB Box + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) + isChildBVisible.value = true + } + // Verify the icon stays as the desired child icon since the cursor hasn't moved + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move to corner of ChildB Box + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) } + // Verify ChildB Box has the desired grandchild icon + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor back to the center of ChildA Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) } + // Dynamically remove the ChildB Box + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) + isChildBVisible.value = false + } + // Verify the icon stays as the desired child icon since the cursor hasn't moved + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Exit hovering over ChildA Box + rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() } + } + + /** + * Setup: The hierarchy for this test is setup as: Default Box (no custom icon set) ⤷ Parent Box + * (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon + * = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = + * [PointerIcon.Hand], overrideDescendants = FALSE) + * + * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the + * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the + * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for + * the remaining surface area not covered by the Child Box. No icon wins for the remaining + * surface area of + * + * Default Box (output icon = null) ⤷ Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child + * Box (output icon = [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand]) + */ + @Test + fun childNotFullyContainedInParent_noOverrideDescendants() { + val defaultIconTag = "myDefaultWrapper" + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.fillMaxSize() + .border(BorderStroke(2.dp, SolidColor(Color.Yellow))) + .testTag(defaultIconTag) + ) { + Box( + modifier = + Modifier.requiredSize(width = 200.dp, height = 150.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Red))) + .testTag(parentIconTag) + .stylusHoverIcon(desiredParentIcon, overrideDescendants = false) + ) { + Box( + Modifier.requiredSize(width = 150.dp, height = 125.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Black))) + .testTag(childIconTag) + .stylusHoverIcon(desiredChildIcon, overrideDescendants = false) + ) { + Box( + Modifier.requiredSize(width = 300.dp, height = 100.dp) + .offset(x = 100.dp) + .border(BorderStroke(2.dp, SolidColor(Color.Blue))) + .testTag(grandchildIconTag) + .stylusHoverIcon( + desiredGrandchildIcon, + overrideDescendants = false + ) + ) + } + } + } + } + } + + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over the default wrapping box and verify the cursor is still the default icon + rule.onNodeWithTag(defaultIconTag).performStylusInput { hoverEnter(center) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Move cursor to the corner of the Grandchild Box and verify it has the desired grandchild + // icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor to the center right of the Child Box and verify it still has the desired + // grandchild icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor to the corner of the Child Box and verify it has updated to the desired child + // icon + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + // Move cursor to the center right of the Parent Box and verify it has the desired + // grandchild icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(centerRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + // Move cursor to the corner of the Parent Box and verify it has the desired parent icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) } + } + + @Test + fun resetPointerIconWhenChildRemoved_parentDoesSetIcon_iconIsHand() { + val defaultIconTag = "myDefaultWrapper" + var show by mutableStateOf(true) + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box( + modifier = + Modifier.fillMaxSize() + .stylusHoverIcon(PointerIcon.Hand) + .testTag(defaultIconTag) + ) { + if (show) { + Box( + modifier = Modifier.stylusHoverIcon(PointerIcon.Text).size(10.dp, 10.dp) + ) + } + } + } + } + + rule.runOnIdle { + // No stylus movement yet, should be default + assertThat(iconService.getStylusHoverIcon()).isNull() + } + + rule.onNodeWithTag(defaultIconTag).performStylusInput { + hoverMoveTo(Offset(x = 5f, y = 5f)) + } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Text) } + + show = false + + rule.onNodeWithTag(defaultIconTag).performStylusInput { + hoverMoveTo(Offset(x = 6f, y = 6f)) + } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Hand) } + } + + @Test + fun resetPointerIconWhenChildRemoved_parentDoesNotSetIcon_iconIsDefault() { + val defaultIconTag = "myDefaultWrapper" + var show by mutableStateOf(true) + rule.setContent { + CompositionLocalProvider(LocalPointerIconService provides iconService) { + Box(modifier = Modifier.fillMaxSize().testTag(defaultIconTag)) { + if (show) { + Box( + modifier = Modifier.stylusHoverIcon(PointerIcon.Text).size(10.dp, 10.dp) + ) + } + } + } + } + + rule.runOnIdle { + // No stylus movement yet, should be default + assertThat(iconService.getStylusHoverIcon()).isNull() + } + + rule.onNodeWithTag(defaultIconTag).performStylusInput { + hoverMoveTo(Offset(x = 5f, y = 5f)) + } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Text) } + + show = false + + rule.onNodeWithTag(defaultIconTag).performStylusInput { + hoverMoveTo(Offset(x = 6f, y = 6f)) + } + + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + } + + private fun verifyIconOnHover(tag: String, expectedIcon: PointerIcon?) { + // Hover over element with specified tag + rule.onNodeWithTag(tag).performStylusInput { hoverEnter(bottomRight) } + // Verify the current icon is the expected icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(expectedIcon) } + // Exit hovering over element + rule.onNodeWithTag(tag).performStylusInput { hoverExit() } + } + + private fun verifyOverlappingSiblings() { + // Verify initial state of pointer icon + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + // Hover over ChildB (bottom right corner) and verify desired ChildB icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(bottomRight) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + + // Then hover to parent (bottom right corner) and verify default arrow icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + + // Then hover back over ChildB in area that overlaps with sibling (bottom left corner) and + // verify desired ChildB icon + rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + rule.runOnIdle { + assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon) + } + + // Then hover to ChildA (bottom left corner) and verify desired ChildA icon (hand) + rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) } + + // Then hover over parent (bottom left corner) and verify default arrow icon + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomLeft) } + rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() } + + // Exit hovering + rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() } + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt index 60a83c09be7d9..be72fd05b9a33 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.input.rotary +import android.view.MotionEvent import android.view.MotionEvent.ACTION_SCROLL import android.view.View import android.view.ViewConfiguration @@ -38,6 +39,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performRotaryScrollInput import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.InputDeviceCompat.SOURCE_ROTARY_ENCODER import androidx.core.view.ViewConfigurationCompat.getScaledHorizontalScrollFactor import androidx.core.view.ViewConfigurationCompat.getScaledVerticalScrollFactor @@ -452,6 +454,41 @@ class RotaryScrollEventTest { } } + @Test + fun onRotary_views_Interop() { + // Arrange + var eventFromOnView: MotionEvent? = null + lateinit var buttonView: View + + rule.setFocusableContent { + AndroidView( + factory = { context -> + android.widget.Button(context).apply { + isFocusable = true + isFocusableInTouchMode = true + text = "Text" + setOnGenericMotionListener { view, event -> + if (view == this) { + eventFromOnView = event + } + false + } + + buttonView = this + } + } + ) + } + rule.runOnIdle { buttonView.requestFocus() } + + // Act. + @OptIn(ExperimentalTestApi::class) + rule.onRoot().performRotaryScrollInput { rotateToScrollVertically(3.0f) } + + // Assert. + rule.runOnIdle { assertThat(eventFromOnView).isNotNull() } + } + private fun Modifier.focusable(initiallyFocused: Boolean = false) = this.then(if (initiallyFocused) Modifier.focusRequester(initialFocus) else Modifier) .focusTarget() diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt index 8370dfdf42cf6..8295452f67a01 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt @@ -18,10 +18,13 @@ package androidx.compose.ui.layout +import androidx.collection.IntObjectMap +import androidx.collection.intObjectMapOf +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusOwner import androidx.compose.ui.geometry.MutableRect @@ -51,6 +54,8 @@ import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.semantics.EmptySemanticsModifier +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -160,7 +165,12 @@ private class FakeOwner( override fun onDetach(node: LayoutNode) {} - override val root: LayoutNode + override val root: LayoutNode = LayoutNode() + + override val semanticsOwner: SemanticsOwner = + SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf()) + + override val layoutNodes: IntObjectMap get() = TODO("Not yet implemented") override val sharedDrawScope: LayoutNodeDrawScope @@ -244,7 +254,8 @@ private class FakeOwner( override val autofill: Autofill get() = TODO("Not yet implemented") - override val semanticAutofill: SemanticAutofill? + @ExperimentalComposeUiApi + override val autofillManager: AutofillManager? get() = TODO("Not yet implemented") override fun createLayer( diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt new file mode 100644 index 0000000000000..dc7f9415ff62c --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt @@ -0,0 +1,834 @@ +/* + * Copyright 2020 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.layout + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.ScrollView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.AtLeastSize +import androidx.compose.ui.FixedSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.SimpleRow +import androidx.compose.ui.Wrap +import androidx.compose.ui.background +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.padding +import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.spatial.RectInfo +import androidx.compose.ui.test.TestActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.sqrt +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@MediumTest +@RunWith(AndroidJUnit4::class) +class OnGlobalRectChangedTest { + + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun handlesChildrenNodeMoveCorrectly() { + val size = 50 + var index by mutableStateOf(0) + var wrap1Position = IntRect.Zero + var wrap2Position = IntRect.Zero + rule.setContent { + SimpleRow { + for (i in 0 until 2) { + if (index == i) { + Wrap( + minWidth = size, + minHeight = size, + modifier = + Modifier.onRectChanged(0, 0) { wrap1Position = it.windowRect } + ) + } else { + Wrap( + minWidth = size, + minHeight = size, + modifier = + Modifier.onRectChanged(0, 0) { wrap2Position = it.windowRect } + ) + } + } + } + } + + rule.runOnIdle { + assertEquals(0, wrap1Position.left) + assertEquals(size, wrap2Position.left) + index = 1 + } + + rule.runOnIdle { + assertEquals(size, wrap1Position.left) + assertEquals(0, wrap2Position.left) + } + } + + @Test + fun callbacksAreCalledWhenChildResized() { + var size by mutableStateOf(10) + var realChildSize = 0 + rule.setContent { + AtLeastSize(size = 20) { + Wrap( + minWidth = size, + minHeight = size, + modifier = + Modifier.onRectChanged(0, 0) { realChildSize = it.rootRect.size.width } + ) + } + } + + rule.runOnIdle { + assertEquals(10, realChildSize) + size = 15 + } + + rule.runOnIdle { assertEquals(15, realChildSize) } + } + + fun IntRect.offset() = IntOffset(left, top) + + @Test + fun callbackCalledForChildWhenParentMoved() { + var position by mutableStateOf(0) + var childGlobalPosition = IntOffset(0, 0) + var latch = CountDownLatch(1) + rule.setContent { + Layout( + measurePolicy = { measurables, constraints -> + layout(10, 10) { measurables[0].measure(constraints).place(position, 0) } + }, + content = { + Wrap(minWidth = 10, minHeight = 10) { + Wrap( + minWidth = 10, + minHeight = 10, + modifier = + Modifier.onRectChanged(0, 0) { rect -> + childGlobalPosition = rect.rootRect.offset() + latch.countDown() + } + ) + } + } + ) + } + + assertTrue(latch.await(1, TimeUnit.SECONDS)) + + latch = CountDownLatch(1) + rule.runOnUiThread { position = 10 } + + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(IntOffset(10, 0), childGlobalPosition) + } + + @Test + fun callbacksAreCalledOnlyForPositionedChildren() { + val latch = CountDownLatch(1) + var wrap1OnPositionedCalled = false + var wrap2OnPositionedCalled = false + rule.setContent { + Layout( + measurePolicy = { measurables, constraints -> + layout(10, 10) { measurables[1].measure(constraints).place(0, 0) } + }, + content = { + Wrap( + minWidth = 10, + minHeight = 10, + modifier = Modifier.onRectChanged(0, 0) { wrap1OnPositionedCalled = true } + ) + Wrap( + minWidth = 10, + minHeight = 10, + modifier = Modifier.onRectChanged(0, 0) { wrap2OnPositionedCalled = true } + ) { + Wrap( + minWidth = 10, + minHeight = 10, + modifier = Modifier.onRectChanged(0, 0) { latch.countDown() } + ) + } + } + ) + } + + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertFalse(wrap1OnPositionedCalled) + assertTrue(wrap2OnPositionedCalled) + } + + @Test + fun globalPositionedModifierUpdateDoesNotInvalidateLayout() { + var lambda1Called = false + var lambda2Called = false + var layoutCalled = false + var placementCalled = false + val lambda1: (RectInfo) -> Unit = { lambda1Called = true } + val lambda2: (RectInfo) -> Unit = { lambda2Called = true } + + val changeLambda = mutableStateOf(true) + + val layoutModifier = + Modifier.layout { measurable, constraints -> + layoutCalled = true + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placementCalled = true + placeable.place(0, 0) + } + } + + rule.setContent { + Box( + modifier = + Modifier.then(layoutModifier) + .size(10.dp) + .onRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2) + ) + } + + rule.runOnIdle { + assertTrue(lambda1Called) + assertTrue(layoutCalled) + assertTrue(placementCalled) + assertFalse(lambda2Called) + } + + lambda1Called = false + lambda2Called = false + layoutCalled = false + placementCalled = false + changeLambda.value = false + + rule.runOnIdle { + assertFalse(lambda2Called) + assertFalse(lambda1Called) + assertFalse(layoutCalled) + assertFalse(placementCalled) + } + } + + @Test + fun callbacksAreCalledOnlyOnceWhenLambdaChangesAndLayoutChanges() { + var lambda1Called = false + val lambda1: (RectInfo) -> Unit = { + assert(!lambda1Called) + lambda1Called = true + } + + var lambda2Called = false + val lambda2: (RectInfo) -> Unit = { + assert(!lambda2Called) + lambda2Called = true + } + + val changeLambda = mutableStateOf(true) + val size = mutableStateOf(100.dp) + rule.setContent { + Box( + modifier = + Modifier.size(size.value) + .onRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2) + ) + } + + rule.runOnIdle { + assertTrue(lambda1Called) + assertFalse(lambda2Called) + } + + lambda1Called = false + lambda2Called = false + size.value = 120.dp + changeLambda.value = false + + rule.runOnIdle { + assertTrue(lambda2Called) + assertFalse(lambda1Called) + } + } + + // change layout below callback, callback only gets called ones + @Test + fun callbacksAreCalledOnlyOnceWhenLayoutBelowItAndLambdaChanged() { + var lambda1Called = false + val lambda1: (RectInfo) -> Unit = { + assert(!lambda1Called) + lambda1Called = true + } + + var lambda2Called = false + val lambda2: (RectInfo) -> Unit = { + assert(!lambda2Called) + lambda2Called = true + } + + val changeLambda = mutableStateOf(true) + val size = mutableStateOf(10.dp) + rule.setContent { + Box( + modifier = + Modifier.padding(10.dp) + .onRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2) + .padding(size.value) + .size(10.dp) + ) + } + + rule.runOnIdle { + assertTrue(lambda1Called) + assertFalse(lambda2Called) + } + + lambda1Called = false + lambda2Called = false + size.value = 20.dp + changeLambda.value = false + + rule.runOnIdle { + assertTrue(lambda2Called) + assertFalse(lambda1Called) + } + } + + @Test + fun onPositionedIsCalledWhenComposeContainerIsScrolled() { + var positionedLatch = CountDownLatch(1) + var coordinates: IntRect? = null + var scrollView: ScrollView? = null + lateinit var view: ComposeView + + rule.runOnUiThread { + scrollView = ScrollView(rule.activity) + rule.activity.setContentView(scrollView, ViewGroup.LayoutParams(100, 100)) + view = ComposeView(rule.activity) + scrollView!!.addView(view) + view.setContent { + Layout( + {}, + modifier = + Modifier.onRectChanged(0, 0) { + coordinates = it.windowRect + positionedLatch.countDown() + } + ) { _, _ -> + layout(100, 200) {} + } + } + } + + rule.waitForIdle() + + assertTrue(positionedLatch.await(1, TimeUnit.SECONDS)) + positionedLatch = CountDownLatch(1) + + rule.runOnIdle { + coordinates = null + scrollView!!.scrollBy(0, 50) + } + + assertTrue( + "OnPositioned is not called when the container scrolled", + positionedLatch.await(1, TimeUnit.SECONDS) + ) + + rule.runOnIdle { assertThat(abs(view.getYInWindow().toInt() - coordinates!!.top) <= 1) } + } + + @Test + fun onPositionedCalledWhenLayerChanged() { + var positionedLatch = CountDownLatch(1) + var coordinates: IntRect? = null + var offsetX by mutableStateOf(0f) + + rule.setContent { + Layout( + {}, + modifier = + Modifier.graphicsLayer { translationX = offsetX } + .onRectChanged(0, 0) { + coordinates = it.windowRect + positionedLatch.countDown() + } + ) { _, _ -> + layout(100, 200) {} + } + } + + rule.waitForIdle() + + assertTrue(positionedLatch.await(1, TimeUnit.SECONDS)) + positionedLatch = CountDownLatch(1) + + rule.runOnIdle { + coordinates = null + offsetX = 5f + } + + assertTrue( + "OnPositioned is not called when the container scrolled", + positionedLatch.await(1, TimeUnit.SECONDS) + ) + + rule.runOnIdle { assertEquals(5, coordinates!!.left) } + } + + private fun View.getYInWindow(): Float { + var offset = 0f + val parentView = parent + if (parentView is View) { + offset += parentView.getYInWindow() + offset -= scrollY.toFloat() + offset += top.toFloat() + } + return offset + } + + @Test + fun onPositionedIsCalledWhenComposeContainerPositionChanged() { + var positionedLatch = CountDownLatch(1) + var coordinates: IntRect? = null + var topView: View? = null + + rule.runOnUiThread { + val linearLayout = LinearLayout(rule.activity) + linearLayout.orientation = LinearLayout.VERTICAL + rule.activity.setContentView(linearLayout, ViewGroup.LayoutParams(100, 200)) + topView = View(rule.activity) + linearLayout.addView(topView!!, ViewGroup.LayoutParams(100, 100)) + val view = ComposeView(rule.activity) + linearLayout.addView(view, ViewGroup.LayoutParams(100, 100)) + view.setContent { + Layout( + {}, + modifier = + Modifier.onRectChanged(0, 0) { + coordinates = it.windowRect + positionedLatch.countDown() + } + ) { _, constraints -> + layout(constraints.maxWidth, constraints.maxHeight) {} + } + } + } + + rule.waitForIdle() + + assertTrue(positionedLatch.await(1, TimeUnit.SECONDS)) + val startY = coordinates!!.top + positionedLatch = CountDownLatch(1) + + rule.runOnIdle { topView!!.visibility = View.GONE } + + assertTrue( + "OnPositioned is not called when the container moved", + positionedLatch.await(1, TimeUnit.SECONDS) + ) + + rule.runOnIdle { assertEquals(startY - 100, coordinates!!.top) } + } + + @Test + fun onPositionedCalledInDifferentPartsOfHierarchy() { + var coordinates1: IntRect? = null + var coordinates2: IntRect? = null + var size by mutableStateOf(10f) + + rule.setContent { + with(LocalDensity.current) { + DelayedMeasure(50) { + Box(Modifier.requiredSize(25.toDp())) { + Box( + Modifier.requiredSize(size.toDp()).onRectChanged(0, 0) { + coordinates1 = it.rootRect + } + ) + } + Box(Modifier.requiredSize(25.toDp())) { + Box( + Modifier.requiredSize(size.toDp()).onRectChanged(0, 0) { + coordinates2 = it.rootRect + } + ) + } + } + } + } + + rule.runOnIdle { + assertNotNull(coordinates1) + assertNotNull(coordinates2) + coordinates1 = null + coordinates2 = null + size = 15f + } + + rule.runOnIdle { + assertNotNull(coordinates1) + assertNotNull(coordinates2) + } + } + + @Test + fun globalCoordinatesAreInActivityCoordinates() { + val padding = 30 + val framePadding = IntOffset(padding, padding) + var realGlobalPosition: IntOffset? = null + var frameGlobalPosition: IntOffset? = null + + val positionedLatch = CountDownLatch(1) + rule.runOnUiThread { + val composeView = ComposeView(rule.activity) + composeView.setPadding(padding, padding, padding, padding) + rule.activity.setContentView(composeView) + + composeView.setContent { + Box( + Modifier.fillMaxSize().onRectChanged(0, 0) { + val position = IntArray(2) + composeView.getLocationInWindow(position) + frameGlobalPosition = IntOffset(position[0], position[1]) + + realGlobalPosition = it.windowRect.offset() + + positionedLatch.countDown() + } + ) + } + } + + rule.waitForIdle() + + assertTrue(positionedLatch.await(1, TimeUnit.SECONDS)) + + rule.runOnIdle { + assertThat(realGlobalPosition).isEqualTo(frameGlobalPosition!! + framePadding) + } + } + + @Test + fun testRepositionTriggersCallback() { + val left = mutableStateOf(30) + var realLeft: Int? = null + + rule.setContent { + with(LocalDensity.current) { + Box { + Box( + Modifier.fillMaxSize().padding(start = left.value.toDp()).onRectChanged( + 0, + 0 + ) { + realLeft = it.rootRect.left + } + ) + } + } + } + + rule.runOnIdle { left.value = 40 } + + rule.runOnIdle { assertThat(realLeft).isEqualTo(40) } + } + + @Test + fun testGrandParentRepositionTriggersChildrenCallback() { + // when we reposition any parent layout is causes the change in global + // position of all the children down the tree(for example during the scrolling). + // children should be able to react on this change. + val left = mutableStateOf(20) + var realLeft: Int? = null + var positionedLatch = CountDownLatch(1) + rule.setContent { + with(LocalDensity.current) { + Box { + Offset(left) { + Box(Modifier.requiredSize(10.toDp())) { + Box(Modifier.requiredSize(10.toDp())) { + Box( + Modifier.onRectChanged(0, 0) { + realLeft = it.rootRect.left + positionedLatch.countDown() + } + .requiredSize(10.toDp()) + ) + } + } + } + } + } + } + assertTrue(positionedLatch.await(1, TimeUnit.SECONDS)) + + positionedLatch = CountDownLatch(1) + rule.runOnUiThread { left.value = 40 } + + assertTrue(positionedLatch.await(1, TimeUnit.SECONDS)) + assertThat(realLeft).isEqualTo(40) + } + + @Test + fun testLayerBoundsPositionInRotatedView() { + var rect: RectInfo? = null + var view: View? = null + var toggle by mutableStateOf(false) + rule.setContent { + view = LocalView.current + if (toggle) { + FixedSize(30, Modifier.padding(10).onRectChanged(0, 0) { rect = it }) { /* no-op */ + } + } + } + + val composeView = view as AndroidComposeView + rule.runOnUiThread { + // rotate the view so that it no longer aligns squarely + composeView.rotation = 45f + composeView.pivotX = 0f + composeView.pivotY = 0f + toggle = !toggle + } + + rule.runOnIdle { + val layoutCoordinates = rect!! + assertEquals(IntOffset(10, 10), layoutCoordinates.rootRect.offset()) + assertEquals(IntRect(10, 10, 40, 40), layoutCoordinates.rootRect) + + val boundsInWindow = layoutCoordinates.windowRect + assertEquals(10f * sqrt(2f), boundsInWindow.top.toFloat(), 1f) + assertEquals(30f * sqrt(2f) / 2f, boundsInWindow.right.toFloat(), 1f) + assertEquals(-30f * sqrt(2f) / 2f, boundsInWindow.left.toFloat(), 1f) + assertEquals(40f * sqrt(2f), boundsInWindow.bottom.toFloat(), 1f) + } + } + + @Test + fun testLayerBoundsPositionInMovedWindow() { + var coords: IntRect? = null + var alignment by mutableStateOf(Alignment.Center) + rule.setContent { + Box(Modifier.fillMaxSize()) { + Popup(alignment = alignment) { + FixedSize( + 30, + Modifier.padding(10).background(Color.Red).onRectChanged(0, 0) { + coords = it.windowRect + } + ) { /* no-op */ + } + } + } + } + + rule.runOnIdle { + val inWindow = coords!!.offset() + assertEquals(10, inWindow.x) + assertEquals(10, inWindow.y) + alignment = Alignment.BottomEnd + } + + rule.runOnIdle { + val inWindow = coords!!.offset() + assertEquals(10, inWindow.x) + assertEquals(10, inWindow.y) + } + } + + @Test + fun coordinatesOfTheModifierAreReported() { + var coords1: IntRect? = null + var coords2: IntRect? = null + var coords3: IntRect? = null + rule.setContent { + Box( + Modifier.fillMaxSize() + .onRectChanged(0, 0) { coords1 = it.windowRect } + .padding(2.dp) + .onRectChanged(0, 0) { coords2 = it.windowRect } + .padding(3.dp) + .onRectChanged(0, 0) { coords3 = it.windowRect } + ) + } + + rule.runOnIdle { + assertEquals(0, coords1!!.offset().x) + val padding1 = with(rule.density) { 2.dp.roundToPx() } + assertEquals(padding1, coords2!!.offset().x) + val padding2 = padding1 + with(rule.density) { 3.dp.roundToPx() } + assertEquals(padding2, coords3!!.offset().x) + } + } + + @Test + @SmallTest + fun modifierIsReturningEqualObjectForTheSameLambda() { + val lambda: (RectInfo) -> Unit = {} + assertEquals(Modifier.onRectChanged(0, 0, lambda), Modifier.onRectChanged(0, 0, lambda)) + } + + @Test + @SmallTest + fun modifierIsReturningNotEqualObjectForDifferentLambdas() { + val lambda1: (RectInfo) -> Unit = { print("foo") } + val lambda2: (RectInfo) -> Unit = { print("bar") } + Assert.assertNotEquals( + Modifier.onRectChanged(0, 0, lambda1), + Modifier.onRectChanged(0, 0, lambda2) + ) + } + + // In some special circumstances, the onGloballyPositioned callbacks can be called recursively + // and they shouldn't crash when that happens. This tests a pointer event causing an + // onGloballyPositioned callback while processing the onGloballyPositioned. + @Test + fun recurseGloballyPositionedCallback() { + val view = rule.activity.findViewById(android.R.id.content) + var offset by mutableStateOf(IntOffset.Zero) + var position = IntOffset.Max + var hasSent = false + rule.setContent { + Box(Modifier.fillMaxSize()) { + Box( + Modifier.fillMaxSize() + .offset { offset } + .onRectChanged(0, 0) { + if (offset != IntOffset.Zero) { + position = it.rootRect.offset() + } + } + ) + Box( + Modifier.fillMaxSize() + .offset { offset } + .onRectChanged(0, 0) { + if (offset != IntOffset.Zero && !hasSent) { + hasSent = true + val now = SystemClock.uptimeMillis() + val event = + MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + view.dispatchTouchEvent(event) + } + } + ) + } + } + rule.runOnIdle { offset = IntOffset(1, 1) } + rule.runOnIdle { assertThat(position).isEqualTo(IntOffset(1, 1)) } + } + + @Test + fun lotsOfNotifications() { + // have more than 16 OnGloballyPositioned liseteners to test listener cache + var offset by mutableStateOf(IntOffset.Zero) + var position = IntOffset.Max + rule.setContent { + Box(Modifier.fillMaxSize()) { + repeat(30) { + Box( + Modifier.fillMaxSize() + .offset { offset } + .onRectChanged(0, 0) { position = it.rootRect.offset() } + ) + } + } + } + rule.runOnIdle { offset = IntOffset(1, 1) } + rule.waitForIdle() + rule.waitForIdle() + rule.runOnIdle { assertThat(position).isEqualTo(IntOffset(1, 1)) } + } + + @Test + fun removingOnPositionedCallbackDoesNotTriggerOtherCallbacks() { + val callbackPresent = mutableStateOf(true) + + var positionCalled1Count = 0 + var positionCalled2Count = 0 + rule.setContent { + val modifier = + if (callbackPresent.value) { + // Remember lambdas to avoid triggering a node update when the lambda changes + Modifier.onRectChanged(0, 0, remember { { positionCalled1Count++ } }) + } else { + Modifier + } + Box( + Modifier + // Remember lambdas to avoid triggering a node update when the lambda changes + .onRectChanged(0, 0, remember { { positionCalled2Count++ } }) + .then(modifier) + .fillMaxSize() + ) + } + + rule.runOnIdle { + // Both callbacks should be called + assertThat(positionCalled1Count).isEqualTo(1) + assertThat(positionCalled2Count).isEqualTo(1) + } + + // Remove the first node + rule.runOnIdle { callbackPresent.value = false } + + rule.runOnIdle { + // Removing the node should not trigger any new callbacks + assertThat(positionCalled1Count).isEqualTo(1) + assertThat(positionCalled2Count).isEqualTo(1) + } + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt index 000f26fe444f5..eb31b922c3a74 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt @@ -1265,7 +1265,7 @@ fun DelayedMeasure(size: Int, modifier: Modifier = Modifier, content: @Composabl } @Composable -private fun Offset(sizeModel: State, content: @Composable () -> Unit) { +internal fun Offset(sizeModel: State, content: @Composable () -> Unit) { // simple copy of Padding which doesn't recompose when the size changes Layout(content) { measurables, constraints -> layout(constraints.maxWidth, constraints.maxHeight) { diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt index 5e34e160d0e26..7d147269bf3dd 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt @@ -2539,6 +2539,15 @@ class SubcomposeLayoutTest { rule.runOnIdle { assertThat(disposeOrder).isExactly("inner 2", "outer", "inner 1") } } + @SdkSuppress( + excludedSdks = + [ + // API 28 is using ViewLayer which invalidates when layer is created + Build.VERSION_CODES.P, + // waitForIdle doesn't wait for draw on API 26 (b/372068529) + Build.VERSION_CODES.O + ] + ) @Test fun precomposeAndPremeasureAreNotCausingViewInvalidations() { val state = SubcomposeLayoutState() diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/modifier/ModifierNodeReuseAndDeactivationTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/modifier/ModifierNodeReuseAndDeactivationTest.kt index e4c3cb1fdefb4..50256449a0dd2 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/modifier/ModifierNodeReuseAndDeactivationTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/modifier/ModifierNodeReuseAndDeactivationTest.kt @@ -649,11 +649,11 @@ class ModifierNodeReuseAndDeactivationTest { } rule.runOnIdle { - assertThat(invalidations).isEqualTo(1) + assertThat(invalidations).isEqualTo(2) counter++ } - rule.runOnIdle { assertThat(invalidations).isEqualTo(2) } + rule.runOnIdle { assertThat(invalidations).isEqualTo(3) } } @Test diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt index 705a09d3d0eb6..67bc5d7ef829b 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt @@ -18,11 +18,13 @@ package androidx.compose.ui.node +import androidx.collection.IntObjectMap +import androidx.collection.intObjectMapOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusOwner @@ -47,6 +49,8 @@ import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.platform.invertTo +import androidx.compose.ui.semantics.EmptySemanticsModifier +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -384,6 +388,9 @@ private class MockOwner( override val density: Density get() = Density(1f) + override val layoutNodes: IntObjectMap + get() = TODO("Not yet implemented") + override val layoutDirection: LayoutDirection get() = LayoutDirection.Ltr @@ -420,6 +427,9 @@ private class MockOwner( override val focusOwner: FocusOwner get() = TODO("Not yet implemented") + override val semanticsOwner: SemanticsOwner = + SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf()) + override val windowInfo: WindowInfo get() = TODO("Not yet implemented") @@ -437,7 +447,7 @@ private class MockOwner( override val autofill: Autofill? get() = TODO("Not yet implemented") - override val semanticAutofill: SemanticAutofill? + override val autofillManager: AutofillManager? get() = TODO("Not yet implemented") override val softwareKeyboardController: SoftwareKeyboardController diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/AndroidComposeViewScreenCoordinatesTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/AndroidComposeViewScreenCoordinatesTest.kt index 0c762dfd79091..4708c4b6a3836 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/AndroidComposeViewScreenCoordinatesTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/AndroidComposeViewScreenCoordinatesTest.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionOnScreen -import androidx.compose.ui.layout.transformToScreen import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/ComposeViewTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/ComposeViewTest.kt index 6a16f3fd2e642..16103cdccbeda 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/ComposeViewTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/ComposeViewTest.kt @@ -46,4 +46,12 @@ class ComposeViewTest { as ViewGroup assertFalse("XML overrides ComposeView.isTransitionGroup", view.isTransitionGroup) } + + @Test + fun createComposeViewWithApplicationContext_doesNotCrash() { + val activity = rule.activity + val view = ComposeView(activity.applicationContext) + rule.runOnIdle { activity.setContentView(view) } + rule.waitForIdle() + } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/RecycledLayersTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/RecycledLayersTest.kt new file mode 100644 index 0000000000000..b91d5d150e84a --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/RecycledLayersTest.kt @@ -0,0 +1,126 @@ +/* + * 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. + */ +package androidx.compose.ui.platform + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isIdentity +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.NodeCoordinator +import androidx.compose.ui.node.Nodes +import androidx.compose.ui.node.OwnedLayer +import androidx.compose.ui.node.requireCoordinator +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class RecycledLayersTest { + @get:Rule val rule = createComposeRule() + + /** + * When drawn content is moved between ComposeViews, the graphics layers should not be shared. + */ + @Test + fun layersKeptInComposeRoot() { + class InvalidatingNode : Modifier.Node(), DrawModifierNode { + var layer: OwnedLayer? = null + + override fun ContentDrawScope.draw() { + layer = findLayer(requireCoordinator(Nodes.Any)) + } + + private fun findLayer(nodeCoordinator: NodeCoordinator): OwnedLayer? { + return nodeCoordinator.layer ?: nodeCoordinator.wrappedBy?.layer + } + } + + var node: InvalidatingNode? = null + + class InvalidatingElement : ModifierNodeElement() { + override fun create(): InvalidatingNode = InvalidatingNode().also { node = it } + + override fun hashCode(): Int = 0 + + override fun equals(other: Any?): Boolean { + return true + } + + override fun update(node: InvalidatingNode) {} + } + + var showDialog by mutableStateOf(false) + rule.setContent { + val content = remember { + movableContentOf { + Box( + Modifier.graphicsLayer { rotationZ = 90f } + .size(50.dp) + .then(InvalidatingElement()) + ) + } + } + Box(Modifier.fillMaxSize()) { + if (!showDialog) { + content() + } else { + Dialog(onDismissRequest = {}) { content() } + } + } + } + rule.waitForIdle() + assertThat(node).isNotNull() + val firstLayer = node?.layer + assertThat(firstLayer).isNotNull() + assertThat(firstLayer!!.underlyingMatrix.isIdentity()).isFalse() + val firstLayerMatrixValues = firstLayer.underlyingMatrix.values.copyOf() + + showDialog = true + rule.waitForIdle() + + val secondLayer = node?.layer + assertThat(secondLayer).isNotNull() + assertThat(secondLayer).isNotSameInstanceAs(firstLayer) + assertThat(secondLayer!!.underlyingMatrix.values.contentEquals(firstLayerMatrixValues)) + .isTrue() + + showDialog = false + rule.waitForIdle() + + val thirdLayer = node?.layer + assertThat(thirdLayer).isNotNull() + // It is likely to be the same instance as firstLayer, but only by coincidence -- it has + // been recycled. We don't want to rely on the recycling behavior here. + assertThat(thirdLayer).isNotSameInstanceAs(secondLayer) + assertThat(thirdLayer!!.underlyingMatrix.values.contentEquals(firstLayerMatrixValues)) + .isTrue() + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/WindowInfoCompositionLocalTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/WindowInfoCompositionLocalTest.kt index e543a0e2915d6..1b625ced8af00 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/WindowInfoCompositionLocalTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/platform/WindowInfoCompositionLocalTest.kt @@ -18,24 +18,31 @@ package androidx.compose.ui.platform import android.view.KeyEvent import android.view.View +import android.widget.FrameLayout +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.setFocusableContent +import androidx.compose.ui.graphics.toComposeIntRect import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.FlakyTest import androidx.test.filters.MediumTest +import androidx.window.layout.WindowMetricsCalculator +import com.google.common.collect.Range import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit.SECONDS +import kotlin.math.roundToInt import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -43,7 +50,7 @@ import org.junit.runner.RunWith @MediumTest @RunWith(AndroidJUnit4::class) class WindowInfoCompositionLocalTest { - @get:Rule val rule = createComposeRule() + @get:Rule val rule = createAndroidComposeRule() @FlakyTest(bugId = 173088588) @Test @@ -270,4 +277,85 @@ class WindowInfoCompositionLocalTest { rule.waitForIdle() assertThat(keyModifiers.packedValue).isEqualTo(0) } + + @Test + fun windowInfo_containerSize() { + // Arrange. + var containerSize = IntSize.Zero + var recompositions = 0 + rule.setContent { + BasicText("Main Window") + val windowInfo = LocalWindowInfo.current + containerSize = windowInfo.containerSize + recompositions++ + } + + // Act. + rule.waitForIdle() + + val expectedWindowSize = + WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(rule.activity) + .bounds + .toComposeIntRect() + .size + + // Assert. + assertThat(containerSize).isEqualTo(expectedWindowSize) + assertThat(recompositions).isEqualTo(1) + } + + // Regression test for b/360343819 + @Test + fun windowInfo_containerSize_viewCreatedWithApplicationContext() { + // Arrange. + var containerSize = IntSize.Zero + var recompositions = 0 + val activity = rule.activity + + rule.runOnUiThread { + val composeView = + ComposeView(activity.applicationContext).apply { + setContent { + BasicText("Main Window") + val windowInfo = LocalWindowInfo.current + containerSize = windowInfo.containerSize + recompositions++ + } + } + + val frameLayout = FrameLayout(activity).apply { addView(composeView) } + + rule.activity.setContentView(frameLayout) + } + + // Act. + rule.waitForIdle() + + val expectedWindowSize = + WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(activity) + .bounds + .toComposeIntRect() + + // For applicationContext we cannot accurately calculate window size (there will be + // differences + // in terms of including / excluding some insets), so just roughly assert we are in the + // correct range + val widthRange = + Range.closed( + (expectedWindowSize.width * 0.8).roundToInt(), + (expectedWindowSize.width * 1.2).roundToInt() + ) + val heightRange = + Range.closed( + (expectedWindowSize.height * 0.8).roundToInt(), + (expectedWindowSize.height * 1.2).roundToInt() + ) + + // Assert. + assertThat(containerSize.width).isIn(widthRange) + assertThat(containerSize.height).isIn(heightRange) + assertThat(recompositions).isEqualTo(1) + } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ColorResourcesTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ColorResourcesTest.kt index e5a4960b0428e..028c1684f5db4 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ColorResourcesTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ColorResourcesTest.kt @@ -16,14 +16,12 @@ package androidx.compose.ui.res -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.ConfigChangeActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.tests.R import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest -import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -33,16 +31,26 @@ import org.junit.runner.RunWith @SmallTest class ColorResourcesTest { - @get:Rule val rule = createComposeRule() + @get:Rule val rule = createAndroidComposeRule() @Test fun colorResourceTest() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - rule.setContent { - CompositionLocalProvider(LocalContext provides context) { - assertThat(colorResource(R.color.color_resource)).isEqualTo(Color(0x12345678)) - } + assertThat(colorResource(R.color.color_resource)).isEqualTo(Color(0x12345678)) } } + + @Test + fun colorResource_observesConfigChanges() { + var color = Color.Unspecified + + rule.activity.setDarkMode(false) + rule.setContent { color = colorResource(R.color.day_night_color) } + + assertThat(color).isEqualTo(Color(0x11223344)) + + rule.activity.setDarkMode(true) + rule.waitForIdle() + assertThat(color).isEqualTo(Color(0x44332211)) + } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ImageResourcesTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ImageResourcesTest.kt new file mode 100644 index 0000000000000..b3816bf547416 --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/ImageResourcesTest.kt @@ -0,0 +1,58 @@ +/* + * 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. + */ + +package androidx.compose.ui.res + +import androidx.compose.testutils.assertPixels +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.test.ConfigChangeActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.tests.R +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SmallTest +class ImageResourcesTest { + + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun imageResourceTest() { + rule.setContent { + val image = ImageBitmap.imageResource(R.drawable.test_image) + image.assertPixels { Color.Red } + } + } + + @Test + fun imageResource_observesConfigChanges() { + var image = ImageBitmap(1, 1) + + rule.activity.setDarkMode(false) + rule.setContent { image = ImageBitmap.imageResource(R.drawable.test_image_day_night) } + + image.assertPixels { Color.Red } + + rule.activity.setDarkMode(true) + rule.waitForIdle() + image.assertPixels { Color.Blue } + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/PrimitiveResourcesTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/PrimitiveResourcesTest.kt index 7f1dbc99ca251..78201f696ae68 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/PrimitiveResourcesTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/res/PrimitiveResourcesTest.kt @@ -16,14 +16,12 @@ package androidx.compose.ui.res -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.ConfigChangeActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.tests.R import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest -import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -33,50 +31,83 @@ import org.junit.runner.RunWith @MediumTest class PrimitiveResourcesTest { - @get:Rule val rule = createComposeRule() + @get:Rule val rule = createAndroidComposeRule() @Test fun integerResourceTest() { - val context = InstrumentationRegistry.getInstrumentation().targetContext + rule.setContent { assertThat(integerResource(R.integer.integer_value)).isEqualTo(123) } + } + @Test + fun integerArrayResourceTest() { rule.setContent { - CompositionLocalProvider(LocalContext provides context) { - assertThat(integerResource(R.integer.integer_value)).isEqualTo(123) - } + assertThat(integerArrayResource(R.array.integer_array)).isEqualTo(intArrayOf(234, 345)) } } @Test - fun integerArrayResourceTest() { - val context = InstrumentationRegistry.getInstrumentation().targetContext + fun booleanResourceTest() { + rule.setContent { assertThat(booleanResource(R.bool.boolean_value)).isTrue() } + } - rule.setContent { - CompositionLocalProvider(LocalContext provides context) { - assertThat(integerArrayResource(R.array.integer_array)) - .isEqualTo(intArrayOf(234, 345)) - } - } + @Test + fun dimensionResourceTest() { + rule.setContent { assertThat(dimensionResource(R.dimen.dimension_value)).isEqualTo(32.dp) } } @Test - fun boolArrayResourceTest() { - val context = InstrumentationRegistry.getInstrumentation().targetContext + fun integerResourceTest_observesConfigChanges() { + var int = 0 - rule.setContent { - CompositionLocalProvider(LocalContext provides context) { - assertThat(booleanResource(R.bool.boolean_value)).isTrue() - } - } + rule.activity.setDarkMode(false) + rule.setContent { int = integerResource(R.integer.day_night_int) } + + assertThat(int).isEqualTo(11) + + rule.activity.setDarkMode(true) + rule.waitForIdle() + assertThat(int).isEqualTo(99) } @Test - fun dimensionResourceTest() { - val context = InstrumentationRegistry.getInstrumentation().targetContext + fun integerArrayResourceTest_observesConfigChanges() { + var intArray = intArrayOf() - rule.setContent { - CompositionLocalProvider(LocalContext provides context) { - assertThat(dimensionResource(R.dimen.dimension_value)).isEqualTo(32.dp) - } - } + rule.activity.setDarkMode(false) + rule.setContent { intArray = integerArrayResource(R.array.day_night_int_array) } + + assertThat(intArray).isEqualTo(intArrayOf(22, 33)) + + rule.activity.setDarkMode(true) + rule.waitForIdle() + assertThat(intArray).isEqualTo(intArrayOf(88, 77)) + } + + @Test + fun booleanResourceTest_observesConfigChanges() { + var bool: Boolean? = null + + rule.activity.setDarkMode(false) + rule.setContent { bool = booleanResource(R.bool.day_night_bool) } + + assertThat(bool).isEqualTo(false) + + rule.activity.setDarkMode(true) + rule.waitForIdle() + assertThat(bool).isEqualTo(true) + } + + @Test + fun dimensionResourceTest_observesConfigChanges() { + var dimen = 0.dp + + rule.activity.setDarkMode(false) + rule.setContent { dimen = dimensionResource(R.dimen.day_night_dimen) } + + assertThat(dimen).isEqualTo(100.dp) + + rule.activity.setDarkMode(true) + rule.waitForIdle() + assertThat(dimen).isEqualTo(200.dp) } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt new file mode 100644 index 0000000000000..2792c5d156636 --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt @@ -0,0 +1,276 @@ +/* + * 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. + */ + +package androidx.compose.ui.semantics + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties.TestTag +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import org.junit.Rule +import org.junit.runner.RunWith + +@MediumTest +@RunWith(AndroidJUnit4::class) +class SemanticsInfoTest { + + @get:Rule val rule = createComposeRule() + + lateinit var semanticsOwner: SemanticsOwner + + @Test + fun contentWithNoSemantics() { + // Arrange. + rule.setTestContent { Box {} } + rule.waitForIdle() + + // Act. + val rootSemantics = semanticsOwner.rootInfo + + // Assert. + assertThat(rootSemantics).isNotNull() + assertThat(rootSemantics.parentInfo).isNull() + assertThat(rootSemantics.childrenInfo.size).isEqualTo(1) + + // Assert extension Functions. + assertThat(rootSemantics.findSemanticsParent()).isNull() + assertThat(rootSemantics.findMergingSemanticsParent()).isNull() + assertThat(rootSemantics.findSemanticsChildren()).isEmpty() + } + + @Test + fun singleSemanticsModifier() { + // Arrange. + rule.setTestContent { Box(Modifier.semantics { this.testTag = "testTag" }) } + rule.waitForIdle() + + // Act. + val rootSemantics = semanticsOwner.rootInfo + val semantics = rule.getSemanticsInfoForTag("testTag")!! + + // Assert. + assertThat(rootSemantics.parentInfo).isNull() + assertThat(rootSemantics.childrenInfo.asMutableList()).containsExactly(semantics) + + assertThat(semantics.parentInfo).isEqualTo(rootSemantics) + assertThat(semantics.childrenInfo.size).isEqualTo(0) + + // Assert extension Functions. + assertThat(rootSemantics.findSemanticsParent()).isNull() + assertThat(rootSemantics.findMergingSemanticsParent()).isNull() + assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration }) + .comparingElementsUsing(SemanticsConfigurationComparator) + .containsExactly(SemanticsConfiguration().apply { testTag = "testTag" }) + + assertThat(semantics.findSemanticsParent()).isEqualTo(rootSemantics) + assertThat(semantics.findMergingSemanticsParent()).isNull() + assertThat(semantics.findSemanticsChildren()).isEmpty() + } + + @Test + fun twoSemanticsModifiers() { + // Arrange. + rule.setTestContent { + Box(Modifier.semantics { this.testTag = "item1" }) + Box(Modifier.semantics { this.testTag = "item2" }) + } + rule.waitForIdle() + + // Act. + val rootSemantics: SemanticsInfo = semanticsOwner.rootInfo + val semantics1 = rule.getSemanticsInfoForTag("item1") + val semantics2 = rule.getSemanticsInfoForTag("item2") + + // Assert. + assertThat(rootSemantics.parentInfo).isNull() + assertThat(rootSemantics.childrenInfo.map { it.semanticsConfiguration }.toList()) + .comparingElementsUsing(SemanticsConfigurationComparator) + .containsExactly( + SemanticsConfiguration().apply { testTag = "item1" }, + SemanticsConfiguration().apply { testTag = "item2" } + ) + .inOrder() + + assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration }) + .comparingElementsUsing(SemanticsConfigurationComparator) + .containsExactly( + SemanticsConfiguration().apply { testTag = "item1" }, + SemanticsConfiguration().apply { testTag = "item2" } + ) + .inOrder() + + checkNotNull(semantics1) + assertThat(semantics1.parentInfo).isEqualTo(rootSemantics) + assertThat(semantics1.childrenInfo.size).isEqualTo(0) + + checkNotNull(semantics2) + assertThat(semantics2.parentInfo).isEqualTo(rootSemantics) + assertThat(semantics2.childrenInfo.size).isEqualTo(0) + + // Assert extension Functions. + assertThat(rootSemantics.findSemanticsParent()).isNull() + assertThat(rootSemantics.findMergingSemanticsParent()).isNull() + assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration }) + .comparingElementsUsing(SemanticsConfigurationComparator) + .containsExactly( + SemanticsConfiguration().apply { testTag = "item1" }, + SemanticsConfiguration().apply { testTag = "item2" } + ) + .inOrder() + + assertThat(semantics1.findSemanticsParent()).isEqualTo(rootSemantics) + assertThat(semantics1.findMergingSemanticsParent()).isNull() + assertThat(semantics1.findSemanticsChildren()).isEmpty() + + assertThat(semantics2.findSemanticsParent()).isEqualTo(rootSemantics) + assertThat(semantics2.findMergingSemanticsParent()).isNull() + assertThat(semantics2.findSemanticsChildren()).isEmpty() + } + + // TODO(ralu): Split this into multiple tests. + @Test + fun nodeDeepInHierarchy() { + // Arrange. + rule.setTestContent { + Column(Modifier.semantics(mergeDescendants = true) { testTag = "outerColumn" }) { + Row(Modifier.semantics { testTag = "outerRow" }) { + Column(Modifier.semantics(mergeDescendants = true) { testTag = "column" }) { + Row(Modifier.semantics { testTag = "row" }) { + Column { + Box(Modifier.semantics { testTag = "box" }) + Row( + Modifier.semantics {} + .semantics { testTag = "testTarget" } + .semantics { testTag = "extra modifier2" } + ) { + Box { Box(Modifier.semantics { testTag = "child1" }) } + Box(Modifier.semantics { testTag = "child2" }) { + Box(Modifier.semantics { testTag = "grandChild" }) + } + Box {} + Row { + Box {} + Box {} + } + Box { Box(Modifier.semantics { testTag = "child3" }) } + } + } + } + } + } + } + } + rule.waitForIdle() + val row = rule.getSemanticsInfoForTag(tag = "row", useUnmergedTree = true) + val column = rule.getSemanticsInfoForTag("column") + + // Act. + val testTarget = rule.getSemanticsInfoForTag(tag = "testTarget", useUnmergedTree = true) + + // Assert. + checkNotNull(testTarget) + assertThat(testTarget.parentInfo).isNotEqualTo(row) + assertThat(testTarget.findSemanticsParent()).isEqualTo(row) + assertThat(testTarget.findMergingSemanticsParent()).isEqualTo(column) + assertThat(testTarget.childrenInfo.size).isEqualTo(5) + assertThat(testTarget.findSemanticsChildren().map { it.semanticsConfiguration }) + .comparingElementsUsing(SemanticsConfigurationComparator) + .containsExactly( + SemanticsConfiguration().apply { testTag = "child1" }, + SemanticsConfiguration().apply { testTag = "child2" }, + SemanticsConfiguration().apply { testTag = "child3" } + ) + .inOrder() + assertThat(testTarget.semanticsConfiguration?.getOrNull(TestTag)).isEqualTo("testTarget") + } + + @Test + fun readingSemanticsConfigurationOfDeactivatedNode() { + // Arrange. + lateinit var lazyListState: LazyListState + lateinit var rootForTest: RootForTest + rule.setContent { + rootForTest = LocalView.current as RootForTest + lazyListState = rememberLazyListState() + LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) { + items(2) { index -> Box(Modifier.size(10.dp).testTag("$index")) } + } + } + val semanticsId = rule.onNodeWithTag("0").semanticsId() + val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId]) + + // Act. + rule.runOnIdle { lazyListState.requestScrollToItem(1) } + val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration } + + // Assert. + rule.runOnIdle { + assertThat(semanticsInfo.isDeactivated).isTrue() + assertThat(semanticsConfiguration).isNull() + } + } + + private fun ComposeContentTestRule.setTestContent(composable: @Composable () -> Unit) { + setContent { + semanticsOwner = (LocalView.current as RootForTest).semanticsOwner + composable() + } + } + + /** Helper function that returns a list of children that is easier to assert on in tests. */ + private fun SemanticsInfo.findSemanticsChildren(): List { + val children = mutableListOf() + this@findSemanticsChildren.findSemanticsChildren { children.add(it) } + return children + } + + private fun ComposeContentTestRule.getSemanticsInfoForTag( + tag: String, + useUnmergedTree: Boolean = true + ): SemanticsInfo? { + return semanticsOwner[onNodeWithTag(tag, useUnmergedTree).semanticsId()] + } + + companion object { + private val SemanticsConfigurationComparator = + Correspondence.from( + { actual, expected -> + actual != null && + expected != null && + actual.getOrNull(TestTag) == expected.getOrNull(TestTag) + }, + "has same test tag as " + ) + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt new file mode 100644 index 0000000000000..77965fe1a936b --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt @@ -0,0 +1,556 @@ +/* + * 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. + */ + +package androidx.compose.ui.semantics + +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.isExactly +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color.Companion.Black +import androidx.compose.ui.graphics.Color.Companion.Red +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.node.invalidateSemantics +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastJoinToString +import androidx.test.filters.MediumTest +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@MediumTest +@RunWith(Parameterized::class) +class SemanticsListenerTest(private val isSemanticAutofillEnabled: Boolean) { + + @get:Rule val rule = createComposeRule() + + private lateinit var semanticsOwner: SemanticsOwner + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "isSemanticAutofillEnabled = {0}") + fun initParameters() = listOf(false, true) + } + + @Before + fun setup() { + @OptIn(ExperimentalComposeUiApi::class) + ComposeUiFlags.isSemanticAutofillEnabled = isSemanticAutofillEnabled + } + + // Initial layout does not trigger listeners. Users have to detect the initial semantics + // values by detecting first layout (You can get the bounds from RectManager.RectList). + @Test + fun initialComposition_doesNotTriggerListeners() { + // Arrange. + val events = mutableListOf>() + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Text(text = "text") + } + + // Assert. + rule.runOnIdle { assertThat(events).isEmpty() } + } + + @Test + fun addingNonSemanticsModifier() { + // Arrange. + val events = mutableListOf>() + var addModifier by mutableStateOf(false) + val text = AnnotatedString("text") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Box( + modifier = + Modifier.then(if (addModifier) Modifier.size(1000.dp) else Modifier) + .semantics { this.text = text } + .testTag("item") + ) + } + + // Act. + rule.runOnIdle { addModifier = true } + + // Assert. + rule.runOnIdle { assertThat(events).isEmpty() } + } + + @Test + fun removingNonSemanticsModifier() { + // Arrange. + val events = mutableListOf>() + var removeModifier by mutableStateOf(false) + val text = AnnotatedString("text") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Box( + modifier = + Modifier.then(if (removeModifier) Modifier else Modifier.size(1000.dp)) + .semantics { this.text = text } + .testTag("item") + ) + } + + // Act. + rule.runOnIdle { removeModifier = true } + + // Assert. + rule.runOnIdle { assertThat(events).isEmpty() } + } + + @Test + fun addingSemanticsModifier() { + // Arrange. + val events = mutableListOf>() + var addModifier by mutableStateOf(false) + val text = AnnotatedString("text") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Box( + modifier = + Modifier.size(100.dp) + .then( + if (addModifier) Modifier.semantics { this.text = text } else Modifier + ) + .testTag("item") + ) + } + + // Act. + rule.runOnIdle { addModifier = true } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly(Event(semanticsId, prevSemantics = null, newSemantics = "text")) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun removingSemanticsModifier() { + // Arrange. + val events = mutableListOf>() + var removeModifier by mutableStateOf(false) + val text = AnnotatedString("text") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Box( + modifier = + Modifier.size(1000.dp) + .then( + if (removeModifier) Modifier + else Modifier.semantics { this.text = text } + ) + .testTag("item") + ) + } + + // Act. + rule.runOnIdle { removeModifier = true } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly(Event(semanticsId, prevSemantics = "text", newSemantics = null)) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun changingMutableSemanticsProperty() { + // Arrange. + val events = mutableListOf>() + var text by mutableStateOf(AnnotatedString("text1")) + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Box(modifier = Modifier.semantics { this.text = text }.testTag("item")) + } + + // Act. + rule.runOnIdle { text = AnnotatedString("text2") } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2")) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun changingMutableSemanticsProperty_alongWithRecomposition() { + // Arrange. + val events = mutableListOf>() + var text by mutableStateOf(AnnotatedString("text1")) + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Box( + modifier = + Modifier.border(2.dp, if (text.text == "text1") Red else Black) + .semantics { this.text = text } + .testTag("item") + ) + } + + // Act. + rule.runOnIdle { text = AnnotatedString("text2") } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2")) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun changingSemanticsProperty_andCallingInvalidateSemantics() { + // Arrange. + val events = mutableListOf>() + val modifierNode = + object : SemanticsModifierNode, Modifier.Node() { + override fun SemanticsPropertyReceiver.applySemantics() {} + } + var text = AnnotatedString("text1") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Box( + modifier = + Modifier.elementFor(modifierNode).semantics { this.text = text }.testTag("item") + ) + } + + // Act. + rule.runOnIdle { + text = AnnotatedString("text2") + modifierNode.invalidateSemantics() + } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2")) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun textChange() { + // Arrange. + val events = mutableListOf>() + var text by mutableStateOf("text1") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Text(text = text, modifier = Modifier.testTag("item")) + } + + // Act. + rule.runOnIdle { text = "text2" } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2")) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun multipleTextChanges() { + // Arrange. + val events = mutableListOf>() + var text by mutableStateOf("text1") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text)) + } + ) { + Text(text = text, modifier = Modifier.testTag("item")) + } + + // Act. + rule.runOnIdle { text = "text2" } + rule.runOnIdle { text = "text3" } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly( + Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"), + Event(semanticsId, prevSemantics = "text2", newSemantics = "text3") + ) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun EditTextChange() { + // Arrange. + val events = mutableListOf>() + var text by mutableStateOf("text1") + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add( + Event( + info.semanticsId, + prev?.EditableText, + info.semanticsConfiguration?.EditableText + ) + ) + } + ) { + TextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.testTag("item") + ) + } + + // Act. + rule.runOnIdle { text = "text2" } + + // Assert. + val semanticsId = rule.onNodeWithTag("item").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2")) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun FocusChange_withNoRecomposition() { + // Arrange. + val events = mutableListOf>() + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add( + Event( + info.semanticsId, + prev?.getOrNull(SemanticsProperties.Focused), + info.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused) + ) + ) + } + ) { + Column { + Box(Modifier.testTag("item1").size(100.dp).focusable()) + Box(Modifier.testTag("item2").size(100.dp).focusable()) + } + } + rule.onNodeWithTag("item1").requestFocus() + rule.runOnIdle { events.clear() } + + // Act. + rule.onNodeWithTag("item2").requestFocus() + + // Assert. + val item1 = rule.onNodeWithTag("item1").semanticsId + val item2 = rule.onNodeWithTag("item2").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly( + Event(item1, prevSemantics = true, newSemantics = false), + Event(item2, prevSemantics = false, newSemantics = true) + ) + } else { + assertThat(events).isEmpty() + } + } + } + + @Test + fun FocusChange_thatCausesRecomposition() { + // Arrange. + val events = mutableListOf>() + rule.setTestContent( + onSemanticsChange = { info, prev -> + events.add( + Event( + info.semanticsId, + prev?.getOrNull(SemanticsProperties.Focused), + info.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused) + ) + ) + } + ) { + Column { + FocusableBox(Modifier.testTag("item1")) + FocusableBox(Modifier.testTag("item2")) + } + } + rule.onNodeWithTag("item1").requestFocus() + rule.runOnIdle { events.clear() } + + // Act. + rule.onNodeWithTag("item2").requestFocus() + + // Assert. + val item1 = rule.onNodeWithTag("item1").semanticsId + val item2 = rule.onNodeWithTag("item2").semanticsId + rule.runOnIdle { + if (isSemanticAutofillEnabled) { + assertThat(events) + .isExactly( + Event(item1, prevSemantics = true, newSemantics = false), + Event(item2, prevSemantics = false, newSemantics = true) + ) + } else { + assertThat(events).isEmpty() + } + } + } + + private val SemanticsConfiguration.Text + get() = getOrNull(SemanticsProperties.Text)?.fastJoinToString() + + private val SemanticsConfiguration.EditableText + get() = getOrNull(SemanticsProperties.EditableText)?.toString() + + private fun ComposeContentTestRule.setTestContent( + onSemanticsChange: (SemanticsInfo, SemanticsConfiguration?) -> Unit, + composable: @Composable () -> Unit + ) { + val semanticsListener = + object : SemanticsListener { + override fun onSemanticsChanged( + semanticsInfo: SemanticsInfo, + previousSemanticsConfiguration: SemanticsConfiguration? + ) { + onSemanticsChange(semanticsInfo, previousSemanticsConfiguration) + } + } + setContent { + semanticsOwner = (LocalView.current as RootForTest).semanticsOwner + DisposableEffect(semanticsOwner) { + semanticsOwner.listeners.add(semanticsListener) + onDispose { semanticsOwner.listeners.remove(semanticsListener) } + } + composable() + } + } + + data class Event(val semanticsId: Int, val prevSemantics: T?, val newSemantics: T?) + + // TODO(b/272068594): Add api to fetch the semantics id from SemanticsNodeInteraction directly. + private val SemanticsNodeInteraction.semanticsId: Int + get() = fetchSemanticsNode().id + + @Composable + private fun FocusableBox( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit = {} + ) { + var borderColor by remember { mutableStateOf(Black) } + Box( + modifier = + modifier + .size(100.dp) + .onFocusChanged { borderColor = if (it.isFocused) Red else Black } + .border(2.dp, borderColor) + .focusable(), + content = content + ) + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt new file mode 100644 index 0000000000000..5147cbecbffb5 --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt @@ -0,0 +1,211 @@ +/* + * 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. + */ + +package androidx.compose.ui.semantics + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.node.elementOf +import androidx.compose.ui.node.invalidateSemantics +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@SmallTest +@RunWith(Parameterized::class) +class SemanticsModifierNodeTest(private val precomputedSemantics: Boolean) { + @get:Rule val rule = createComposeRule() + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "pre-computed semantics = {0}") + fun initParameters() = listOf(false, true) + } + + @Before + fun setup() { + @OptIn(ExperimentalComposeUiApi::class) + ComposeUiFlags.isSemanticAutofillEnabled = precomputedSemantics + } + + @Test + fun applySemantics_calledWhenSemanticsIsRead() { + // Arrange. + var applySemanticsInvoked = false + rule.setContent { + Box( + Modifier.elementOf( + TestSemanticsModifier { + testTag = "TestTag" + applySemanticsInvoked = true + } + ) + ) + } + + // Act. + rule.onNodeWithTag("TestTag").fetchSemanticsNode() + + // Assert. + rule.runOnIdle { assertThat(applySemanticsInvoked).isTrue() } + } + + @Test + fun invalidateSemantics_applySemanticsIsCalled() { + // Arrange. + var applySemanticsInvoked: Boolean + val semanticsModifier = TestSemanticsModifier { + testTag = "TestTag" + applySemanticsInvoked = true + } + rule.setContent { Box(Modifier.elementOf(semanticsModifier)) } + applySemanticsInvoked = false + + // Act. + rule.runOnIdle { semanticsModifier.invalidateSemantics() } + + // Assert - Apply semantics is not called when we calculate semantics lazily. + if (precomputedSemantics) { + assertThat(applySemanticsInvoked).isTrue() + } else { + assertThat(applySemanticsInvoked).isFalse() + } + } + + @Test + fun invalidateSemantics_applySemanticsNotCalledAgain_whenSemanticsConfigurationIsRead() { + // Arrange. + lateinit var rootForTest: RootForTest + var applySemanticsInvoked = false + var invocationCount = 0 + val semanticsModifier = TestSemanticsModifier { + testTag = "TestTag" + text = AnnotatedString("Text ${invocationCount++}") + applySemanticsInvoked = true + } + rule.setContent { + rootForTest = LocalView.current as RootForTest + Box(Modifier.elementOf(semanticsModifier)) + } + val semanticsId = rule.onNodeWithTag("TestTag").semanticsId() + rule.runOnIdle { + semanticsModifier.invalidateSemantics() + applySemanticsInvoked = false + } + + // Act. + val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId]) + val semanticsConfiguration = semanticsInfo.semanticsConfiguration + + // Assert - Configuration recalculated when we calculate semantics lazily. + if (precomputedSemantics) { + assertThat(applySemanticsInvoked).isFalse() + } else { + assertThat(applySemanticsInvoked).isTrue() + } + assertThat(semanticsConfiguration?.text()).containsExactly("Text 2") + } + + @Test + fun readingSemanticsConfigurationOfDeactivatedNode() { + // Arrange. + lateinit var lazyListState: LazyListState + lateinit var rootForTest: RootForTest + rule.setContent { + rootForTest = LocalView.current as RootForTest + lazyListState = rememberLazyListState() + LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) { + items(2) { index -> + Box(Modifier.size(10.dp).testTag("$index").elementOf(TestSemanticsModifier {})) + } + } + } + val semanticsId = rule.onNodeWithTag("0").semanticsId() + val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId]) + + // Act. + rule.runOnIdle { lazyListState.requestScrollToItem(1) } + val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration } + + // Assert. + rule.runOnIdle { + assertThat(semanticsInfo.isDeactivated).isTrue() + assertThat(semanticsConfiguration).isNull() + } + } + + @Test + fun readingSemanticsConfigurationOfDeactivatedNode_afterCallingInvalidate() { + // Arrange. + lateinit var lazyListState: LazyListState + lateinit var rootForTest: RootForTest + val semanticsModifierNodes = List(2) { TestSemanticsModifier {} } + rule.setContent { + rootForTest = LocalView.current as RootForTest + lazyListState = rememberLazyListState() + LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) { + items(2) { index -> + Box( + Modifier.size(10.dp) + .testTag("$index") + .elementOf(semanticsModifierNodes[index]) + ) + } + } + } + val semanticsId = rule.onNodeWithTag("0").semanticsId() + val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId]) + + // Act. + rule.runOnIdle { lazyListState.requestScrollToItem(1) } + semanticsModifierNodes[0].invalidateSemantics() + val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration } + + // Assert. + rule.runOnIdle { + assertThat(semanticsInfo.isDeactivated).isTrue() + assertThat(semanticsConfiguration).isNull() + } + } + + fun SemanticsConfiguration.text() = getOrNull(SemanticsProperties.Text)?.map { it.text } + + class TestSemanticsModifier( + private val onApplySemantics: SemanticsPropertyReceiver.() -> Unit + ) : SemanticsModifierNode, Modifier.Node() { + override fun SemanticsPropertyReceiver.applySemantics() { + onApplySemantics.invoke(this) + } + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ConfigChangeActivity.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ConfigChangeActivity.kt new file mode 100644 index 0000000000000..220a70afa6d8e --- /dev/null +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/ConfigChangeActivity.kt @@ -0,0 +1,41 @@ +/* + * 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. + */ + +package androidx.compose.ui.test + +import android.content.res.Configuration +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.ui.platform.ComposeView + +class ConfigChangeActivity : AppCompatActivity() { + + fun setDarkMode(isDark: Boolean) { + val mode = if (isDark) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO + runOnUiThread { delegate.apply { localNightMode = mode } } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + // propagate config changes to the compose hierarchy, see b/352336694 + val composeView = + window.decorView.findViewById(android.R.id.content).getChildAt(0) + as? ComposeView + composeView?.dispatchConfigurationChanged(newConfig) + } +} diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapperTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapperTest.kt index fbebdf2782203..2f4b8df600b5b 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapperTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapperTest.kt @@ -29,13 +29,14 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify @SmallTest @RunWith(AndroidJUnit4::class) class NullableInputConnectionWrapperTest { - private val delegate = mock() + private var delegate = mock() @Test fun delegatesToDelegate() { @@ -103,4 +104,25 @@ class NullableInputConnectionWrapperTest { verify(delegate, never()).setSelection(any(), any()) } + + @Test + fun getSelectedTextReturnsNull_whenDelegateIsDisposed() { + val ic = NullableInputConnectionWrapper(delegate, onConnectionClosed = {}) + + ic.disposeDelegate() + val result = ic.getSelectedText(0) + + verify(delegate, never()).getSelectedText(any()) + assertThat(result).isNull() + } + + @Test + fun getSelectedTextReturnsNull_whenDelegateReturnsNull() { + val ic = NullableInputConnectionWrapper(delegate, onConnectionClosed = {}) + + val result = ic.getSelectedText(0) + + verify(delegate, times(1)).getSelectedText(any()) + assertThat(result).isNull() + } } diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/VelocityTrackingListParityTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/VelocityTrackingListParityTest.kt index 9ba98f34ef7f2..971570922d2a4 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/VelocityTrackingListParityTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/VelocityTrackingListParityTest.kt @@ -52,6 +52,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import kotlin.coroutines.resume import kotlin.math.absoluteValue +import kotlin.test.Ignore import kotlin.test.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -206,6 +207,7 @@ class VelocityTrackingListParityTest { } } + @Ignore // b/373631123 @Test fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_largeFast() = runBlocking { val state = LazyListState() @@ -248,6 +250,7 @@ class VelocityTrackingListParityTest { } } + @Ignore // b/371570954 @Test fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_largeVeryFast() = runBlocking { val state = LazyListState() diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/DialogTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/DialogTest.kt index 11ba95256404f..d209bec32d20c 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/DialogTest.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/DialogTest.kt @@ -15,6 +15,7 @@ */ package androidx.compose.ui.window +import android.graphics.Point import android.util.DisplayMetrics import android.view.Gravity import android.view.KeyEvent @@ -318,6 +319,104 @@ class DialogTest { rule.runOnIdle { assertEquals(1f, value) } } + @Test + fun smallDialogHasSmallWindowDefaultWidthDecorFits() { + lateinit var dialogView: View + rule.setContent { + Dialog( + {}, + properties = + DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = true) + ) { + dialogView = LocalView.current + Box(Modifier.size(with(LocalDensity.current) { 100.toDp() })) + } + } + rule.runOnIdle { + var root = dialogView + while (root.parent is View) { + root = root.parent as View + } + assertThat(root.width).isEqualTo(100) + assertThat(root.height).isEqualTo(100) + } + } + + @Test + fun smallDialogHasSmallWindowNotDefaultWidthDecorFits() { + lateinit var dialogView: View + rule.setContent { + Dialog( + {}, + properties = + DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true) + ) { + dialogView = LocalView.current + Box(Modifier.size(with(LocalDensity.current) { 100.toDp() })) + } + } + rule.runOnIdle { + var root = dialogView + while (root.parent is View) { + root = root.parent as View + } + assertThat(root.height).isEqualTo(100) + } + } + + @Test + fun smallDialogHasSmallWindowDefaultWidthNoDecorFits() { + lateinit var dialogView: View + rule.setContent { + Dialog( + {}, + properties = + DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = false) + ) { + dialogView = LocalView.current + Box(Modifier.size(with(LocalDensity.current) { 100.toDp() })) + } + } + rule.runOnIdle { + val point = Point() + @Suppress("DEPRECATION") dialogView.display.getRealSize(point) + var root = dialogView + while (root.parent is View) { + root = root.parent as View + } + // For some reason, when decorFitsSystemWindows = false, the window doesn't + // WRAP_CONTENT the dialog, but the width should be less than the full width of the + // screen. + assertThat(root.width).isLessThan(point.x) + assertThat(root.height).isEqualTo(100) + } + } + + @Test + fun smallDialogHasSmallWindowNotDefaultWidthNoDecorFits() { + lateinit var dialogView: View + rule.setContent { + Dialog( + {}, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + dialogView = LocalView.current + Box(Modifier.size(with(LocalDensity.current) { 100.toDp() })) + } + } + rule.runOnIdle { + var root = dialogView + while (root.parent is View) { + root = root.parent as View + } + assertThat(root.height).isEqualTo(100) + } + } + @Test fun canFillScreenWidth_dependingOnProperty() { var box1Width = 0 @@ -416,6 +515,72 @@ class DialogTest { @Test fun dismissWhenClickingOutsideContent() { + var dismissed = false + var clicked = false + lateinit var composeView: View + val clickBoxTag = "clickBox" + rule.setContent { + Dialog( + onDismissRequest = { dismissed = true }, + properties = + DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true) + ) { + composeView = LocalView.current + Box(Modifier.size(10.dp).testTag(clickBoxTag).clickable { clicked = true }) + } + } + + // click inside the compose view + rule.onNodeWithTag(clickBoxTag).performClick() + + rule.waitForIdle() + + assertThat(dismissed).isFalse() + assertThat(clicked).isTrue() + + clicked = false + + // click outside the compose view + rule.waitForIdle() + var root = composeView + while (root.parent is View) { + root = root.parent as View + } + + rule.runOnIdle { + val x = 1f + val y = 1f + val down = + MotionEvent( + eventTime = 0, + action = ACTION_DOWN, + numPointers = 1, + actionIndex = 0, + pointerProperties = arrayOf(PointerProperties(0)), + pointerCoords = arrayOf(PointerCoords(x, y)), + root + ) + root.dispatchTouchEvent(down) + val up = + MotionEvent( + eventTime = 10, + action = ACTION_UP, + numPointers = 1, + actionIndex = 0, + pointerProperties = arrayOf(PointerProperties(0)), + pointerCoords = arrayOf(PointerCoords(x, y)), + root + ) + root.dispatchTouchEvent(up) + } + rule.waitForIdle() + + assertThat(dismissed).isTrue() + assertThat(clicked).isFalse() + } + + @Test + fun dismissWhenClickingOutsideContentNoDecorFitsSystemWindows() { var dismissed = false var clicked = false lateinit var composeView: View @@ -452,8 +617,8 @@ class DialogTest { } rule.runOnIdle { - val x = root.width / 4f - val y = root.height / 4f + val x = 1f + val y = 1f val down = MotionEvent( eventTime = 0, @@ -519,6 +684,25 @@ class DialogTest { } } + @Test + fun dialogDefaultGravityIsCenter() { + lateinit var view: View + rule.setContent { + Dialog(onDismissRequest = {}) { + view = LocalView.current + Box(Modifier.size(10.dp)) + } + } + rule.runOnIdle { + var provider = view + while (provider !is DialogWindowProvider) { + provider = view.parent as View + } + val window = provider.window + assertThat(window.attributes.gravity).isEqualTo(Gravity.CENTER) + } + } + private fun setupDialogTest( closeDialogOnDismiss: Boolean = true, dialogProperties: DialogProperties = DialogProperties(), diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/PopupTestUtils.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/PopupTestUtils.kt index ed8acb3f85b85..e439a71198bef 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/PopupTestUtils.kt +++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/window/PopupTestUtils.kt @@ -61,6 +61,7 @@ internal class PopupLayoutMatcher(val testTag: String) : TypeSafeMatcher() override fun matchesSafely(item: Root?): Boolean { val matches = item != null && isPopupLayout(item.decorView, testTag) if (matches) { + @Suppress("DEPRECATION") lastSeenWindowParams = item!!.windowLayoutParams.get() } return matches diff --git a/compose/ui/ui/src/androidInstrumentedTest/res/drawable-night/test_image_day_night.png b/compose/ui/ui/src/androidInstrumentedTest/res/drawable-night/test_image_day_night.png new file mode 100644 index 0000000000000000000000000000000000000000..fa9a0202a90ff745ff9210ac276dd010d8b9876b GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^Od!m`1|*BN@u~nR&H|6fVg?50=OD~@c~}U&Kt&>+E{-7_Gm}#ie*B-`2n3ys aTnw_3OiV{bPAmn=GkCiCxvX~}U&Kt&>+E{-7_Gn4~}U&Kt&>+E{-7_Gn4 + + + + true + #44332211 + 200dp + 99 + + 88 + 77 + + \ No newline at end of file diff --git a/compose/ui/ui/src/androidInstrumentedTest/res/values/donottranslate-strings.xml b/compose/ui/ui/src/androidInstrumentedTest/res/values/donottranslate-strings.xml index 6c1cc2ba7eb5f..23e3fd36a2da1 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/res/values/donottranslate-strings.xml +++ b/compose/ui/ui/src/androidInstrumentedTest/res/values/donottranslate-strings.xml @@ -26,13 +26,6 @@ string2 - 123 - - - 234 - 345 - - There is one Android here There are a number of Androids here @@ -42,10 +35,4 @@ There is %d Android here There are %d Androids here - - true - - 32dp - - #12345678 diff --git a/compose/ui/ui/src/androidInstrumentedTest/res/values/resources.xml b/compose/ui/ui/src/androidInstrumentedTest/res/values/resources.xml index a4e772111c850..377bc9f97f4f6 100644 --- a/compose/ui/ui/src/androidInstrumentedTest/res/values/resources.xml +++ b/compose/ui/ui/src/androidInstrumentedTest/res/values/resources.xml @@ -1,3 +1,4 @@ + + false + #11223344 + 100dp + 11 + + 22 + 33 + \ No newline at end of file diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/Actual.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/Actual.android.kt new file mode 100644 index 0000000000000..e6dd10e722df0 --- /dev/null +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/Actual.android.kt @@ -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. + */ + +package androidx.compose.ui + +import android.os.Handler +import android.os.Looper + +private val handler = Handler(Looper.getMainLooper()) + +internal actual fun postDelayed(delayMillis: Long, block: () -> Unit): Any { + val runnable = Runnable { block() } + handler.postDelayed(runnable, delayMillis) + return runnable +} + +internal actual fun removePost(token: Any?) { + token as? Runnable ?: return + handler.removeCallbacks(token) +} diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidSemanticAutofill.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt similarity index 88% rename from compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidSemanticAutofill.android.kt rename to compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt index f0ec7f9d09559..f1f056792b781 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidSemanticAutofill.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt @@ -25,17 +25,15 @@ import android.util.Log import android.util.SparseArray import android.view.View import android.view.ViewStructure -import android.view.autofill.AutofillManager +import android.view.autofill.AutofillManager as PlatformAndroidManager import android.view.autofill.AutofillValue import android.view.inputmethod.EditorInfo import androidx.annotation.RequiresApi -import androidx.collection.ArraySet import androidx.collection.IntObjectMap import androidx.collection.MutableIntObjectMap import androidx.collection.intObjectMapOf import androidx.collection.mutableIntObjectMapOf import androidx.compose.ui.internal.checkPreconditionNotNull -import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.platform.AndroidComposeView import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextClassName import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextFieldClassName @@ -64,17 +62,14 @@ import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.state.ToggleableState.On import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.util.fastForEach -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay /** * Semantic autofill implementation for Android. * * @param view The parent compose view. */ -// TODO(b/333102566): Make this class public when Autofill is ready to go live @RequiresApi(Build.VERSION_CODES.O) -internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticAutofill { +internal class AndroidAutofillManager(val view: AndroidComposeView) : AutofillManager { internal var autofillManager: AutofillManagerWrapper = AutofillManagerWrapperImpl(view) init { @@ -93,19 +88,10 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA SemanticsNodeCopy(view.semanticsOwner.unmergedRootSemanticsNode, intObjectMapOf()) private var checkingForSemanticsChanges = false - private val subtreeChangedLayoutNodes = ArraySet() - private val boundsUpdateChannel = Channel(1) internal var currentSemanticsNodesInvalidated = true - - // TODO(333102566): This is internal for testing for now. - internal var _TEMP_AUTOFILL_FLAG = false - - /** - * Delay before dispatching a recurring accessibility event in milliseconds. This delay - * guarantees that a recurring event will be send at most once during the - * [SendRecurringAutofillEventsIntervalMillis] time frame. - */ - internal var SendRecurringAutofillEventsIntervalMillis = 100L + // This will be used to request autofill when `AutofillManager.requestAutofill()` is called + // (e.g. from the text toolbar). + private var previouslyFocusedId = 0 internal var currentSemanticsNodes: IntObjectMap = intObjectMapOf() @@ -117,19 +103,6 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA return field } - internal suspend fun boundsUpdatesEventLoop() { - try { - if (!checkingForSemanticsChanges) { - checkingForSemanticsChanges = true - handler.post(autofillChangeChecker) - } - subtreeChangedLayoutNodes.clear() - delay(SendRecurringAutofillEventsIntervalMillis) - } finally { - subtreeChangedLayoutNodes.clear() - } - } - private fun updateSemanticsCopy() { previousSemanticsNodes.clear() currentSemanticsNodes.forEach { key, value -> @@ -141,7 +114,6 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA } private val autofillChangeChecker = Runnable { - view.measureAndLayout() checkForAutofillPropertyChanges(currentSemanticsNodes) updateSemanticsCopy() checkingForSemanticsChanges = false @@ -178,6 +150,7 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA val currFocus = currNode.unmergedConfig.getOrNull(Focused) if (previousFocus != true && currFocus == true) { notifyViewEntered(id) + previouslyFocusedId = id } if (previousFocus == true && currFocus != true) { notifyViewExited(id) @@ -202,14 +175,6 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA } } - internal fun onLayoutChange(layoutNode: LayoutNode) { - currentSemanticsNodesInvalidated = true - - if (subtreeChangedLayoutNodes.add(layoutNode)) { - boundsUpdateChannel.trySend(Unit) - } - } - private fun notifyViewEntered(semanticsId: Int) { currentSemanticsNodes[semanticsId]?.adjustedBounds?.let { autofillManager.notifyViewEntered(semanticsId, it) @@ -222,7 +187,8 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA private fun notifyAutofillValueChanged(semanticsId: Int, newAutofillValue: Any) { val currSemanticsNode = currentSemanticsNodes[semanticsId]?.semanticsNode - val currDataType = currSemanticsNode?.unmergedConfig?.getOrNull(SemanticsContentDataType) + val currDataType = + currSemanticsNode?.unmergedConfig?.getOrNull(SemanticsContentDataType) ?: return when (currDataType) { ContentDataType.Text -> @@ -246,10 +212,20 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA autofillManager.notifyViewVisibilityChanged(semanticsId, !isInvisible) } - override fun notifyAutofillCommit() { + override fun commit() { autofillManager.commit() } + override fun cancel() { + autofillManager.cancel() + } + + override fun requestAutofillForActiveElement() { + currentSemanticsNodes[previouslyFocusedId]?.let { + autofillManager.requestAutofill(previouslyFocusedId, it.adjustedBounds) + } + } + internal fun onTextFillHelper(toFillId: Int, autofillValue: String) { // Use mapping to find lambda corresponding w semanticsNodeId, // then invoke the lambda. This will change the field text. @@ -268,7 +244,7 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA * This callback is called when we receive autofill events. It adds some logs that can be * useful for debug purposes. */ - internal object AutofillSemanticCallback : AutofillManager.AutofillCallback() { + internal object AutofillSemanticCallback : PlatformAndroidManager.AutofillCallback() { override fun onAutofillEvent(view: View, virtualId: Int, event: Int) { super.onAutofillEvent(view, virtualId, event) Log.d( @@ -296,20 +272,20 @@ internal class AndroidSemanticAutofill(val view: AndroidComposeView) : SemanticA } /** Registers the autofill debug callback. */ - fun register(semanticAutofill: AndroidSemanticAutofill) { - semanticAutofill.autofillManager.autofillManager.registerCallback(this) + fun register(androidAutofillManager: AndroidAutofillManager) { + androidAutofillManager.autofillManager.autofillManager.registerCallback(this) } /** Unregisters the autofill debug callback. */ - fun unregister(semanticAutofill: AndroidSemanticAutofill) { - semanticAutofill.autofillManager.autofillManager.unregisterCallback(this) + fun unregister(androidAutofillManager: AndroidAutofillManager) { + androidAutofillManager.autofillManager.autofillManager.unregisterCallback(this) } } } } @RequiresApi(Build.VERSION_CODES.O) -internal fun AndroidSemanticAutofill.populateViewStructure(root: ViewStructure) { +internal fun AndroidAutofillManager.populateViewStructure(root: ViewStructure) { // Add child nodes. The function returns the index to the first item. val count = currentSemanticsNodes.count { _, semanticsNodeWithAdjustedBounds -> @@ -320,6 +296,8 @@ internal fun AndroidSemanticAutofill.populateViewStructure(root: ViewStructure) SemanticsContentDataType ) } + // TODO(b/138549623): Instead of creating a flattened tree by using the nodes from the map, we + // can use SemanticsOwner to get the root SemanticsInfo and create a more representative tree. var index = AutofillApi26Helper.addChildCount(root, count) // Iterate through currentSemanticsNodes, finding autofill-related nodes @@ -377,6 +355,8 @@ internal fun SemanticsNode.populateViewStructure(child: ViewStructure) { // ———————— Visibility, elevation, alpha // Transparency should be the only thing affecting View.VISIBLE (pruning will take care of all // covered nodes). + // TODO(mnuzen): since we are removing pruning in semantics/accessibility with `semanticInfo`, + // double check that this is the correct behavior even after switching. AutofillApi26Helper.setVisibility( child, if (!isTransparent || isRoot) View.VISIBLE else View.INVISIBLE @@ -459,7 +439,7 @@ internal fun SemanticsNode.populateViewStructure(child: ViewStructure) { } @RequiresApi(Build.VERSION_CODES.O) -internal fun AndroidSemanticAutofill.performAutofill(values: SparseArray) { +internal fun AndroidAutofillManager.performAutofill(values: SparseArray) { for (index in 0 until values.size()) { val itemId = values.keyAt(index) val value = values[itemId] @@ -479,7 +459,7 @@ internal fun AndroidSemanticAutofill.performAutofill(values: SparseArray Text - AUTOFILL_TYPE_LIST -> List - AUTOFILL_TYPE_DATE -> Date - AUTOFILL_TYPE_TOGGLE -> Toggle - AUTOFILL_TYPE_NONE -> None - else -> throw IllegalArgumentException("Invalid autofill type value: $value") - } - } } } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/ContentType.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/ContentType.android.kt index 3789395c1b9cf..e7be7b1ffeb5c 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/ContentType.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/ContentType.android.kt @@ -57,10 +57,10 @@ import androidx.autofill.HintConstants.AUTOFILL_HINT_USERNAME * Gets the Android specific [AutofillHint][android.view.ViewStructure.setAutofillHints] * corresponding to the [ContentType]. */ -internal actual class ContentType private constructor(internal val contentHints: Set) { +actual class ContentType private constructor(internal val contentHints: Set) { actual constructor(contentHint: String) : this(setOf(contentHint)) - internal actual companion object { + actual companion object { // Define constants for predefined autofill hints actual val Username = ContentType(AUTOFILL_HINT_USERNAME) actual val Password = ContentType(AUTOFILL_HINT_PASSWORD) @@ -101,51 +101,9 @@ internal actual class ContentType private constructor(internal val contentHints: actual val BirthDateMonth = ContentType(AUTOFILL_HINT_BIRTH_DATE_MONTH) actual val BirthDateYear = ContentType(AUTOFILL_HINT_BIRTH_DATE_YEAR) actual val SmsOtpCode = ContentType(AUTOFILL_HINT_SMS_OTP) - - internal actual fun from(value: String): ContentType { - return when (value) { - AUTOFILL_HINT_EMAIL_ADDRESS -> EmailAddress - AUTOFILL_HINT_USERNAME -> Username - AUTOFILL_HINT_PASSWORD -> Password - AUTOFILL_HINT_NEW_USERNAME -> NewUsername - AUTOFILL_HINT_NEW_PASSWORD -> NewPassword - AUTOFILL_HINT_POSTAL_ADDRESS -> PostalAddress - AUTOFILL_HINT_POSTAL_CODE -> PostalCode - AUTOFILL_HINT_CREDIT_CARD_NUMBER -> CreditCardNumber - AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> CreditCardSecurityCode - AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE -> CreditCardExpirationDate - AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> CreditCardExpirationMonth - AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> CreditCardExpirationYear - AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY -> CreditCardExpirationDay - AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY -> AddressCountry - AUTOFILL_HINT_POSTAL_ADDRESS_REGION -> AddressRegion - AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY -> AddressLocality - AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS -> AddressStreet - AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_ADDRESS -> AddressAuxiliaryDetails - AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_POSTAL_CODE -> PostalCodeExtended - AUTOFILL_HINT_PERSON_NAME -> PersonFullName - AUTOFILL_HINT_PERSON_NAME_GIVEN -> PersonFirstName - AUTOFILL_HINT_PERSON_NAME_FAMILY -> PersonLastName - AUTOFILL_HINT_PERSON_NAME_MIDDLE -> PersonMiddleName - AUTOFILL_HINT_PERSON_NAME_MIDDLE_INITIAL -> PersonMiddleInitial - AUTOFILL_HINT_PERSON_NAME_PREFIX -> PersonNamePrefix - AUTOFILL_HINT_PERSON_NAME_SUFFIX -> PersonNameSuffix - AUTOFILL_HINT_PHONE_NUMBER -> PhoneNumber - AUTOFILL_HINT_PHONE_NUMBER_DEVICE -> PhoneNumberDevice - AUTOFILL_HINT_PHONE_COUNTRY_CODE -> PhoneCountryCode - AUTOFILL_HINT_PHONE_NATIONAL -> PhoneNumberNational - AUTOFILL_HINT_GENDER -> Gender - AUTOFILL_HINT_BIRTH_DATE_FULL -> BirthDateFull - AUTOFILL_HINT_BIRTH_DATE_DAY -> BirthDateDay - AUTOFILL_HINT_BIRTH_DATE_MONTH -> BirthDateMonth - AUTOFILL_HINT_BIRTH_DATE_YEAR -> BirthDateYear - AUTOFILL_HINT_SMS_OTP -> SmsOtpCode - else -> ContentType(value) - } - } } - internal operator fun plus(other: ContentType): ContentType { + operator fun plus(other: ContentType): ContentType { val combinedValues = contentHints + other.contentHints return ContentType(combinedValues) } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedback.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedback.android.kt index cfb1627fe2515..4e8000ab5974e 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedback.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedback.android.kt @@ -24,17 +24,53 @@ internal class PlatformHapticFeedback(private val view: View) : HapticFeedback { override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { when (hapticFeedbackType) { + HapticFeedbackType.Confirm -> + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + HapticFeedbackType.ContextClick -> + view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + HapticFeedbackType.GestureEnd -> + view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END) + HapticFeedbackType.GestureThresholdActivate -> + view.performHapticFeedback(HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE) HapticFeedbackType.LongPress -> view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + HapticFeedbackType.Reject -> view.performHapticFeedback(HapticFeedbackConstants.REJECT) + HapticFeedbackType.SegmentFrequentTick -> + view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK) + HapticFeedbackType.SegmentTick -> + view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_TICK) HapticFeedbackType.TextHandleMove -> view.performHapticFeedback(HapticFeedbackConstants.TEXT_HANDLE_MOVE) + HapticFeedbackType.ToggleOff -> + view.performHapticFeedback(HapticFeedbackConstants.TOGGLE_OFF) + HapticFeedbackType.ToggleOn -> + view.performHapticFeedback(HapticFeedbackConstants.TOGGLE_ON) + HapticFeedbackType.VirtualKey -> + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) } } } internal actual object PlatformHapticFeedbackType { + actual val Confirm: HapticFeedbackType = HapticFeedbackType(HapticFeedbackConstants.CONFIRM) + actual val ContextClick: HapticFeedbackType = + HapticFeedbackType(HapticFeedbackConstants.CONTEXT_CLICK) + actual val GestureEnd: HapticFeedbackType = + HapticFeedbackType(HapticFeedbackConstants.GESTURE_END) + actual val GestureThresholdActivate: HapticFeedbackType = + HapticFeedbackType(HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE) actual val LongPress: HapticFeedbackType = HapticFeedbackType(HapticFeedbackConstants.LONG_PRESS) + actual val Reject: HapticFeedbackType = HapticFeedbackType(HapticFeedbackConstants.REJECT) + actual val SegmentFrequentTick: HapticFeedbackType = + HapticFeedbackType(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK) + actual val SegmentTick: HapticFeedbackType = + HapticFeedbackType(HapticFeedbackConstants.SEGMENT_TICK) actual val TextHandleMove: HapticFeedbackType = HapticFeedbackType(HapticFeedbackConstants.TEXT_HANDLE_MOVE) + actual val ToggleOff: HapticFeedbackType = + HapticFeedbackType(HapticFeedbackConstants.TOGGLE_OFF) + actual val ToggleOn: HapticFeedbackType = HapticFeedbackType(HapticFeedbackConstants.TOGGLE_ON) + actual val VirtualKey: HapticFeedbackType = + HapticFeedbackType(HapticFeedbackConstants.VIRTUAL_KEY) } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.android.kt index cdac70c18fbad..e91227cc088b2 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.android.kt @@ -59,7 +59,10 @@ internal actual constructor( internal val motionEvent: MotionEvent? get() = internalPointerEvent?.motionEvent - /** Returns `MotionEvent`'s classification. */ + /** + * Returns + * [`MotionEvent`'s classification](https://developer.android.com/reference/android/view/MotionEvent#getClassification()). + */ @get:MotionEventClassification val classification: Int get() = diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.android.kt deleted file mode 100644 index 17b1415f131b9..0000000000000 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.android.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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. - */ - -@file:JvmName("LayoutCoordinatesKt") -@file:JvmMultifileClass - -package androidx.compose.ui.layout - -import androidx.compose.ui.geometry.isSpecified -import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.input.pointer.MatrixPositionCalculator -import androidx.compose.ui.node.NodeCoordinator -import androidx.compose.ui.node.requireOwner - -/** - * Takes a [matrix] which transforms some coordinate system `C` to local coordinates, and updates - * the matrix to transform from `C` to screen coordinates instead. - */ -@Suppress("DocumentExceptions") -fun LayoutCoordinates.transformToScreen(matrix: Matrix) { - val rootCoordinates = findRootCoordinates() - - // transformFrom resets matrix, so apply it to temporary one. - val tmpMatrix = Matrix() - rootCoordinates.transformFrom(this, tmpMatrix) - matrix *= tmpMatrix - - val owner = toCoordinatorOrNull()?.layoutNode?.requireOwner() as? MatrixPositionCalculator - if (owner != null) { - owner.localToScreen(matrix) - } else { - // Fallback: try to extract just position - val screenPosition = rootCoordinates.positionOnScreen() - if (screenPosition.isSpecified) { - matrix.translate(screenPosition.x, screenPosition.y, 0f) - } - } -} - -private fun LayoutCoordinates.toCoordinatorOrNull() = - (this as? LookaheadLayoutCoordinates)?.coordinator ?: this as? NodeCoordinator diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt index 1d599a71331b4..db16500060ad3 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt @@ -34,6 +34,7 @@ import android.os.SystemClock import android.util.LongSparseArray import android.util.SparseArray import android.view.FocusFinder +import android.view.InputDevice import android.view.KeyEvent as AndroidKeyEvent import android.view.MotionEvent import android.view.MotionEvent.ACTION_CANCEL @@ -63,6 +64,8 @@ import android.view.translation.ViewTranslationResponse import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting +import androidx.collection.MutableIntObjectMap +import androidx.collection.mutableIntObjectMapOf import androidx.compose.runtime.collection.mutableVectorOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -71,14 +74,16 @@ import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.SessionMutex import androidx.compose.ui.autofill.AndroidAutofill -import androidx.compose.ui.autofill.AndroidSemanticAutofill +import androidx.compose.ui.autofill.AndroidAutofillManager import androidx.compose.ui.autofill.Autofill import androidx.compose.ui.autofill.AutofillCallback +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree import androidx.compose.ui.autofill.performAutofill import androidx.compose.ui.autofill.populateViewStructure @@ -189,6 +194,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.round import androidx.compose.ui.util.fastIsFinite import androidx.compose.ui.util.fastLastOrNull import androidx.compose.ui.util.fastRoundToInt @@ -296,7 +302,7 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC override val dragAndDropManager = AndroidDragAndDropManager(::startDrag) - private val _windowInfo: WindowInfoImpl = WindowInfoImpl() + private val _windowInfo: LazyWindowInfo = LazyWindowInfo() override val windowInfo: WindowInfo get() = _windowInfo @@ -426,9 +432,12 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC .then(dragAndDropManager.modifier) } + override val layoutNodes: MutableIntObjectMap = mutableIntObjectMapOf() + override val rootForTest: RootForTest = this - override val semanticsOwner: SemanticsOwner = SemanticsOwner(root, rootSemanticsNode) + override val semanticsOwner: SemanticsOwner = + SemanticsOwner(root, rootSemanticsNode, layoutNodes) private val composeAccessibilityDelegate = AndroidComposeViewAccessibilityDelegateCompat(this) internal var contentCaptureManager = AndroidContentCaptureManager( @@ -476,12 +485,15 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC private val _autofill = if (autofillSupported()) AndroidAutofill(this, autofillTree) else null + internal val _autofillManager = if (autofillSupported()) AndroidAutofillManager(this) else null + // Used as a CompositionLocal for performing autofill. override val autofill: Autofill? get() = _autofill // Used as a CompositionLocal for performing semantic autofill. - override val semanticAutofill = if (autofillSupported()) AndroidSemanticAutofill(this) else null + override val autofillManager: AutofillManager? + get() = _autofillManager private var observationClearRequested = false @@ -1017,17 +1029,14 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC */ override fun setAccessibilityEventBatchIntervalMillis(intervalMillis: Long) { composeAccessibilityDelegate.SendRecurringAccessibilityEventsIntervalMillis = intervalMillis - - if (SDK_INT >= 26) { - // TODO(333102566): add a setAutofillEventBatchIntervalMillis instead of using the - // accessibility interval here. - semanticAutofill?.SendRecurringAutofillEventsIntervalMillis = intervalMillis - } } - override fun onAttach(node: LayoutNode) {} + override fun onAttach(node: LayoutNode) { + layoutNodes[node.semanticsId] = node + } override fun onDetach(node: LayoutNode) { + layoutNodes.remove(node.semanticsId) measureAndLayoutDelegate.onNodeDetached(node) requestClearInvalidObservations() @OptIn(ExperimentalComposeUiApi::class) @@ -1063,10 +1072,6 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC // to the front of the list, so removing in a chunk is cheaper than removing one-by-one endApplyChangesListeners.removeRange(0, size) } - @OptIn(ExperimentalComposeUiApi::class) - if (ComposeUiFlags.isRectTrackingEnabled) { - rectManager.dispatchCallbacks() - } } override fun registerOnEndApplyChangesListener(listener: () -> Unit) { @@ -1485,23 +1490,30 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC var positionChanged = false getLocationOnScreen(tmpPositionArray) val (globalX, globalY) = globalPosition - if (globalX != tmpPositionArray[0] || globalY != tmpPositionArray[1]) { + if ( + globalX != tmpPositionArray[0] || + globalY != tmpPositionArray[1] || + // -1 means it has never been set, 0 means it has been "reset". We only want to + // catch the "never been set" case + lastMatrixRecalculationAnimationTime < 0L + ) { globalPosition = IntOffset(tmpPositionArray[0], tmpPositionArray[1]) if (globalX != Int.MAX_VALUE && globalY != Int.MAX_VALUE) { positionChanged = true root.layoutDelegate.measurePassDelegate.notifyChildrenUsingCoordinatesWhilePlacing() } } + recalculateWindowPosition() + rectManager.updateOffsets(globalPosition, windowPosition.round(), viewToWindowMatrix) measureAndLayoutDelegate.dispatchOnPositionedCallbacks(forceDispatch = positionChanged) - } - - override fun onDraw(canvas: android.graphics.Canvas) { @OptIn(ExperimentalComposeUiApi::class) if (ComposeUiFlags.isRectTrackingEnabled) { rectManager.dispatchCallbacks() } } + override fun onDraw(canvas: android.graphics.Canvas) {} + override fun createLayer( drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit, invalidateParentLayer: () -> Unit, @@ -1577,27 +1589,22 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC if (cacheValue) { layerCache.push(layer) } + dirtyLayers -= layer return cacheValue } override fun onSemanticsChange() { composeAccessibilityDelegate.onSemanticsChange() contentCaptureManager.onSemanticsChange() - // TODO(b/333102566): Use _semanticAutofill's `onSemanticsChange` after semantic autofill - // goes live. - if (SDK_INT >= 26 && semanticAutofill?._TEMP_AUTOFILL_FLAG == true) { - semanticAutofill.onSemanticsChange() + @OptIn(ExperimentalComposeUiApi::class) + if (SDK_INT >= 26 && isSemanticAutofillEnabled) { + _autofillManager?.onSemanticsChange() } } override fun onLayoutChange(layoutNode: LayoutNode) { composeAccessibilityDelegate.onLayoutChange(layoutNode) contentCaptureManager.onLayoutChange(layoutNode) - // TODO(b/333102566): Use _semanticAutofill's `onLayoutChange` after semantic autofill - // goes live. - if (SDK_INT >= 26 && semanticAutofill?._TEMP_AUTOFILL_FLAG == true) { - semanticAutofill.onLayoutChange(layoutNode) - } } override val rectManager = RectManager() @@ -1750,6 +1757,8 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC override fun onAttachedToWindow() { super.onAttachedToWindow() _windowInfo.isWindowFocused = hasWindowFocus() + _windowInfo.setOnInitializeContainerSize { calculateWindowSize(this) } + updateWindowMetrics() invalidateLayoutNodeMeasurement(root) invalidateLayers(root) snapshotObserver.startObserving() @@ -1816,6 +1825,7 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC override fun onDetachedFromWindow() { super.onDetachedFromWindow() snapshotObserver.stopObserving() + _windowInfo.setOnInitializeContainerSize(null) val lifecycle = checkPreconditionNotNull(viewTreeOwners?.lifecycleOwner?.lifecycle) { "No lifecycle owner exists" @@ -1836,20 +1846,22 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC if (SDK_INT >= S) AndroidComposeViewTranslationCallbackS.clearViewTranslationCallback(this) } + @OptIn(ExperimentalComposeUiApi::class) override fun onProvideAutofillVirtualStructure(structure: ViewStructure?, flags: Int) { if (autofillSupported() && structure != null) { - if (semanticAutofill?._TEMP_AUTOFILL_FLAG == true) { - semanticAutofill.populateViewStructure(structure) + if (isSemanticAutofillEnabled) { + _autofillManager?.populateViewStructure(structure) } else { _autofill?.populateViewStructure(structure) } } } + @OptIn(ExperimentalComposeUiApi::class) override fun autofill(values: SparseArray) { if (autofillSupported()) { - if (semanticAutofill?._TEMP_AUTOFILL_FLAG == true) { - semanticAutofill.performAutofill(values) + if (isSemanticAutofillEnabled) { + _autofillManager?.performAutofill(values) } else { _autofill?.performAutofill(values) } @@ -1949,7 +1961,9 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC uptimeMillis = event.eventTime, inputDeviceId = event.deviceId ) - return focusOwner.dispatchRotaryEvent(rotaryEvent) + return focusOwner.dispatchRotaryEvent(rotaryEvent) { + super.dispatchGenericMotionEvent(event) + } } private fun handleMotionEvent(motionEvent: MotionEvent): ProcessResult { @@ -2269,6 +2283,10 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC viewToWindowMatrix.invertTo(windowToViewMatrix) } + private fun updateWindowMetrics() { + _windowInfo.updateContainerSizeIfObserved { calculateWindowSize(this) } + } + override fun onCheckIsTextEditor(): Boolean { val parentSession = textInputSessionMutex.currentSession @@ -2301,6 +2319,7 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) density = Density(context) + updateWindowMetrics() if (newConfig.fontWeightAdjustmentCompat != currentFontWeightAdjustment) { currentFontWeightAdjustment = newConfig.fontWeightAdjustmentCompat fontFamilyResolver = createFontFamilyResolver(context) @@ -2426,23 +2445,55 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC return null } + @RequiresApi(N) + override fun onResolvePointerIcon( + event: MotionEvent, + pointerIndex: Int + ): android.view.PointerIcon { + val toolType = event.getToolType(pointerIndex) + if ( + !event.isFromSource(InputDevice.SOURCE_MOUSE) && + event.isFromSource(InputDevice.SOURCE_STYLUS) && + (toolType == MotionEvent.TOOL_TYPE_STYLUS || + toolType == MotionEvent.TOOL_TYPE_ERASER) + ) { + val icon = pointerIconService.getStylusHoverIcon() + if (icon != null) { + return AndroidComposeViewVerificationHelperMethodsN.toAndroidPointerIcon( + context, + icon + ) + } + } + return super.onResolvePointerIcon(event, pointerIndex) + } + override val pointerIconService: PointerIconService = object : PointerIconService { - private var currentIcon: PointerIcon = PointerIcon.Default + private var currentMouseCursorIcon: PointerIcon = PointerIcon.Default + private var currentStylusHoverIcon: PointerIcon? = null override fun getIcon(): PointerIcon { - return currentIcon + return currentMouseCursorIcon } override fun setIcon(value: PointerIcon?) { - currentIcon = value ?: PointerIcon.Default + currentMouseCursorIcon = value ?: PointerIcon.Default if (SDK_INT >= N) { AndroidComposeViewVerificationHelperMethodsN.setPointerIcon( this@AndroidComposeView, - currentIcon + currentMouseCursorIcon ) } } + + override fun getStylusHoverIcon(): PointerIcon? { + return currentStylusHoverIcon + } + + override fun setStylusHoverIcon(value: PointerIcon?) { + currentStylusHoverIcon = value + } } /** @@ -2583,20 +2634,22 @@ private object AndroidComposeViewAssistHelperMethodsO { @RequiresApi(N) private object AndroidComposeViewVerificationHelperMethodsN { + @RequiresApi(N) + fun toAndroidPointerIcon(context: Context, icon: PointerIcon?): android.view.PointerIcon = + when (icon) { + is AndroidPointerIcon -> icon.pointerIcon + is AndroidPointerIconType -> android.view.PointerIcon.getSystemIcon(context, icon.type) + else -> + android.view.PointerIcon.getSystemIcon( + context, + android.view.PointerIcon.TYPE_DEFAULT + ) + } + @DoNotInline @RequiresApi(N) fun setPointerIcon(view: View, icon: PointerIcon?) { - val iconToSet = - when (icon) { - is AndroidPointerIcon -> icon.pointerIcon - is AndroidPointerIconType -> - android.view.PointerIcon.getSystemIcon(view.context, icon.type) - else -> - android.view.PointerIcon.getSystemIcon( - view.context, - android.view.PointerIcon.TYPE_DEFAULT - ) - } + val iconToSet = toAndroidPointerIcon(view.context, icon) if (view.pointerIcon != iconToSet) { view.pointerIcon = iconToSet diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt index 7036817b23cc4..af22fff9e6cf0 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.platform import android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK import android.content.Context +import android.content.res.Resources import android.graphics.RectF import android.os.Build import android.os.Bundle @@ -182,6 +183,20 @@ private object RtlBoundsComparator : Comparator { } } +private val semanticComparators: Array> = + Array(2) { index -> + val comparator = + when (index) { + 0 -> RtlBoundsComparator + else -> LtrBoundsComparator + } + comparator + // then compare by layoutNode's zIndex and placement order + .thenBy(LayoutNode.ZComparator) { it.layoutNode } + // then compare by semanticsId to break the tie somehow + .thenBy { it.id } + } + // Kotlin `sortWith` should just pull out the highest traversal indices, but keep everything // else in place. If the element does not have a `traversalIndex` then `0f` will be used. private val UnmergedConfigComparator: (SemanticsNode, SemanticsNode) -> Int = { a, b -> @@ -370,7 +385,7 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo currentSemanticsNodesInvalidated = false field = view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap() if (isEnabled) { - setTraversalValues() + setTraversalValues(field, idToBeforeMap, idToAfterMap, view.context.resources) } } return field @@ -540,226 +555,13 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo ) } - private val semanticComparators: Array> = - Array(2) { index -> - val comparator = - when (index) { - 0 -> RtlBoundsComparator - else -> LtrBoundsComparator - } - comparator - // then compare by layoutNode's zIndex and placement order - .thenBy(LayoutNode.ZComparator) { it.layoutNode } - // then compare by semanticsId to break the tie somehow - .thenBy { it.id } - } - - @Suppress("NOTHING_TO_INLINE") - private inline fun semanticComparator(layoutIsRtl: Boolean): Comparator { - return semanticComparators[if (layoutIsRtl) 0 else 1] - } - - // check to see if this entry overlaps with any groupings in rowGroupings - private fun placedEntryRowOverlaps( - rowGroupings: ArrayList>>, - node: SemanticsNode - ): Boolean { - // Conversion to long is needed in order to utilize `until`, which has no float ver - val entryTopCoord = node.boundsInWindow.top - val entryBottomCoord = node.boundsInWindow.bottom - val entryIsEmpty = entryTopCoord >= entryBottomCoord - - for (currIndex in 0..rowGroupings.lastIndex) { - val currRect = rowGroupings[currIndex].first - val groupIsEmpty = currRect.top >= currRect.bottom - val groupOverlapsEntry = - !entryIsEmpty && - !groupIsEmpty && - max(entryTopCoord, currRect.top) < min(entryBottomCoord, currRect.bottom) - - // If it overlaps with this row group, update cover and add node - if (groupOverlapsEntry) { - val newRect = - currRect.intersect(0f, entryTopCoord, Float.POSITIVE_INFINITY, entryBottomCoord) - // Replace the cover rectangle, copying over the old list of nodes - rowGroupings[currIndex] = Pair(newRect, rowGroupings[currIndex].second) - // Add current node - rowGroupings[currIndex].second.add(node) - // We've found an overlapping group, return true - return true - } - } - - // If we've made it here, then there are no groups our entry overlaps with - return false - } - - /** - * Returns the results of geometry groupings, which is determined from 1) grouping nodes into - * distinct, non-overlapping rows based on their top/bottom coordinates, then 2) sorting nodes - * within each row with the semantics comparator. - * - * This method approaches traversal order with more nuance than an approach considering only - * just hierarchy or only just an individual node's bounds. - * - * If [containerChildrenMapping] exists, there are additional children to add, as well as the - * sorted parent itself - */ - private fun sortByGeometryGroupings( - layoutIsRtl: Boolean, - parentListToSort: ArrayList, - containerChildrenMapping: MutableIntObjectMap> = - mutableIntObjectMapOf() - ): MutableList { - // RowGroupings list consists of pairs, first = a rectangle of the bounds of the row - // and second = the list of nodes in that row - val rowGroupings = - ArrayList>>(parentListToSort.size / 2) - - for (entryIndex in 0..parentListToSort.lastIndex) { - val currEntry = parentListToSort[entryIndex] - // If this is the first entry, or vertical groups don't overlap - if (entryIndex == 0 || !placedEntryRowOverlaps(rowGroupings, currEntry)) { - val newRect = currEntry.boundsInWindow - rowGroupings.add(Pair(newRect, mutableListOf(currEntry))) - } // otherwise, we've already iterated through, found and placed it in a matching group - } - - // Sort the rows from top to bottom - rowGroupings.sortWith(TopBottomBoundsComparator) - - val returnList = ArrayList() - val comparator = semanticComparator(layoutIsRtl) - rowGroupings.fastForEach { row -> - // Sort each individual row's parent nodes - row.second.sortWith(comparator) - returnList.addAll(row.second) - } - - returnList.sortWith(UnmergedConfigComparator) - - var i = 0 - // Afterwards, go in and add the containers' children. - while (i <= returnList.lastIndex) { - val currNodeId = returnList[i].id - // If a parent node is a container, then add its children. - // Add all container's children after the container itself. - // Because we've already recursed on the containers children, the children should - // also be sorted by their traversal index - val containersChildrenList = containerChildrenMapping[currNodeId] - if (containersChildrenList != null) { - val containerIsScreenReaderFocusable = isScreenReaderFocusable(returnList[i]) - if (!containerIsScreenReaderFocusable) { - // Container is removed if it is not screenreader-focusable - returnList.removeAt(i) - } else { - // Increase counter if the container was not removed - i += 1 - } - // Add all the container's children and increase counter by the number of children - returnList.addAll(i, containersChildrenList) - i += containersChildrenList.size - } else { - // Advance to the next item - i += 1 - } - } - return returnList - } - - private fun geometryDepthFirstSearch( - currNode: SemanticsNode, - geometryList: ArrayList, - containerMapToChildren: MutableIntObjectMap> - ) { - val currRTL = currNode.isRtl - // We only want to add children that are either traversalGroups or are - // screen reader focusable. The child must also be in the current pruned semantics tree. - val isTraversalGroup = - currNode.unmergedConfig.getOrElse(SemanticsProperties.IsTraversalGroup) { false } - - if ( - (isTraversalGroup || isScreenReaderFocusable(currNode)) && - currentSemanticsNodes.containsKey(currNode.id) - ) { - geometryList.add(currNode) - } - if (isTraversalGroup) { - // Recurse and record the container's children, sorted - containerMapToChildren[currNode.id] = - subtreeSortedByGeometryGrouping(currRTL, currNode.children) - } else { - // Otherwise, continue adding children to the list that'll be sorted regardless of - // hierarchy - currNode.children.fastForEach { child -> - geometryDepthFirstSearch(child, geometryList, containerMapToChildren) - } - } - } - - /** - * This function prepares a subtree for `sortByGeometryGroupings` by retrieving all - * non-container nodes and adding them to the list to be geometrically sorted. We recurse on - * containers (if they exist) and add their sorted children to an optional mapping. The list to - * be sorted and child mapping is passed into `sortByGeometryGroupings`. - */ - private fun subtreeSortedByGeometryGrouping( - layoutIsRtl: Boolean, - listToSort: List - ): MutableList { - // This should be mapping of [containerID: listOfSortedChildren], only populated if there - // are container nodes in this level. If there are container nodes, `containerMapToChildren` - // would look like {containerId: [sortedChild, sortedChild], containerId: [sortedChild]} - val containerMapToChildren = mutableIntObjectMapOf>() - val geometryList = ArrayList() - - listToSort.fastForEach { node -> - geometryDepthFirstSearch(node, geometryList, containerMapToChildren) - } - - return sortByGeometryGroupings(layoutIsRtl, geometryList, containerMapToChildren) - } - - private fun setTraversalValues() { - idToBeforeMap.clear() - idToAfterMap.clear() - - val hostSemanticsNode = - currentSemanticsNodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]?.semanticsNode!! - val hostLayoutIsRtl = hostSemanticsNode.isRtl - - val semanticsOrderList = - subtreeSortedByGeometryGrouping(hostLayoutIsRtl, listOf(hostSemanticsNode)) - - // Iterate through our ordered list, and creating a mapping of current node to next node ID - // We'll later read through this and set traversal order with IdToBeforeMap - for (i in 1..semanticsOrderList.lastIndex) { - val prevId = semanticsOrderList[i - 1].id - val currId = semanticsOrderList[i].id - idToBeforeMap[prevId] = currId - idToAfterMap[currId] = prevId - } - } - - private fun isScreenReaderFocusable(node: SemanticsNode): Boolean { - val nodeContentDescriptionOrNull = - node.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull() - val isSpeakingNode = - nodeContentDescriptionOrNull != null || - getInfoText(node) != null || - getInfoStateDescriptionOrNull(node) != null || - getInfoIsCheckable(node) - - return !node.isHidden && - (node.unmergedConfig.isMergingSemanticsOfDescendants || - node.isUnmergedLeafNode && isSpeakingNode) - } - private fun populateAccessibilityNodeInfoProperties( virtualViewId: Int, info: AccessibilityNodeInfoCompat, semanticsNode: SemanticsNode ) { + val resources = view.context.resources + // set classname info.className = ClassName @@ -775,9 +577,9 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo role?.let { if (semanticsNode.isFake || semanticsNode.replacedChildren.isEmpty()) { if (role == Role.Tab) { - info.roleDescription = view.context.resources.getString(R.string.tab) + info.roleDescription = resources.getString(R.string.tab) } else if (role == Role.Switch) { - info.roleDescription = view.context.resources.getString(R.string.switch_role) + info.roleDescription = resources.getString(R.string.switch_role) } else { val className = role.toLegacyClassName() // Images are often minor children of larger widgets, so we only want to @@ -832,8 +634,8 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo setText(semanticsNode, info) setContentInvalid(semanticsNode, info) - setStateDescription(semanticsNode, info) - setIsCheckable(semanticsNode, info) + info.stateDescription = getInfoStateDescriptionOrNull(semanticsNode, resources) + info.isCheckable = getInfoIsCheckable(semanticsNode) val toggleState = semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState) @@ -1227,7 +1029,7 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo } } - info.isScreenReaderFocusable = isScreenReaderFocusable(semanticsNode) + info.isScreenReaderFocusable = isScreenReaderFocusable(semanticsNode, resources) // `beforeId` refers to the semanticsId that should be read before this `virtualViewId`. val beforeId = idToBeforeMap.getOrDefault(virtualViewId, -1) @@ -1275,143 +1077,6 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo } } - private fun getInfoStateDescriptionOrNull(node: SemanticsNode): String? { - var stateDescription = node.unmergedConfig.getOrNull(SemanticsProperties.StateDescription) - val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState) - val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role) - - // Check toggle state and retrieve description accordingly - toggleState?.let { - when (it) { - ToggleableState.On -> { - // Unfortunately, talkback has a bug of using "checked", so we set state - // description here - if (role == Role.Switch && stateDescription == null) { - stateDescription = view.context.resources.getString(R.string.state_on) - } - } - ToggleableState.Off -> { - // Unfortunately, talkback has a bug of using "not checked", so we set state - // description here - if (role == Role.Switch && stateDescription == null) { - stateDescription = view.context.resources.getString(R.string.state_off) - } - } - ToggleableState.Indeterminate -> { - if (stateDescription == null) { - stateDescription = view.context.resources.getString(R.string.indeterminate) - } - } - } - } - - // Check Selected property and retrieve description accordingly - node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let { - if (role != Role.Tab) { - if (stateDescription == null) { - // If a radio entry (radio button + text) is selectable, it won't have the role - // RadioButton, so if we use info.isCheckable/info.isChecked, talkback will say - // "checked/not checked" instead "selected/note selected". - stateDescription = - if (it) { - view.context.resources.getString(R.string.selected) - } else { - view.context.resources.getString(R.string.not_selected) - } - } - } - } - - // Check if a node has progress bar range info and retrieve description accordingly - val rangeInfo = node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo) - rangeInfo?.let { - // let's set state description here and use state description change events. - // otherwise, we need to send out type_view_selected event, as the old android - // versions do. But the support for type_view_selected event for progress bars - // maybe deprecated in talkback in the future. - if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) { - if (stateDescription == null) { - val valueRange = rangeInfo.range - val progress = - (if (valueRange.endInclusive - valueRange.start == 0f) 0f - else - (rangeInfo.current - valueRange.start) / - (valueRange.endInclusive - valueRange.start)) - .fastCoerceIn(0f, 1f) - - // We only display 0% or 100% when it is exactly 0% or 100%. - val percent = - when (progress) { - 0f -> 0 - 1f -> 100 - else -> (progress * 100).fastRoundToInt().coerceIn(1, 99) - } - stateDescription = - view.context.resources.getString(R.string.template_percent, percent) - } - } else if (stateDescription == null) { - stateDescription = view.context.resources.getString(R.string.in_progress) - } - } - - if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) { - stateDescription = createStateDescriptionForTextField(node) - } - - return stateDescription - } - - /** - * Empty text field should not be ignored by the TB so we set a state description. When there is - * a speakable child, like a label or a placeholder text, setting this state description is - * redundant - */ - private fun createStateDescriptionForTextField(node: SemanticsNode): String? { - val mergedConfig = node.copyWithMergingEnabled().config - val mergedNodeIsUnspeakable = - mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() && - mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() && - mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty() - return if (mergedNodeIsUnspeakable) { - view.context.resources.getString(R.string.state_empty) - } else null - } - - private fun setStateDescription( - node: SemanticsNode, - info: AccessibilityNodeInfoCompat, - ) { - info.stateDescription = getInfoStateDescriptionOrNull(node) - } - - private fun getInfoIsCheckable(node: SemanticsNode): Boolean { - var isCheckable = false - val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState) - val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role) - - toggleState?.let { isCheckable = true } - - node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let { - if (role != Role.Tab) { - isCheckable = true - } - } - - return isCheckable - } - - private fun setIsCheckable(node: SemanticsNode, info: AccessibilityNodeInfoCompat) { - info.isCheckable = getInfoIsCheckable(node) - } - - // This needs to be here instead of around line 3000 because we need access to the `view` - // that is inside the `AndroidComposeViewAccessibilityDelegateCompat` class - private fun getInfoText(node: SemanticsNode): AnnotatedString? { - val editableTextToAssign = node.unmergedConfig.getTextForTextField() - val textToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull() - return editableTextToAssign ?: textToAssign - } - @OptIn(InternalTextApi::class) private fun AnnotatedString.toSpannableString(): SpannableString? { val fontFamilyResolver: FontFamily.Resolver = view.fontFamilyResolver @@ -2355,11 +2020,11 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo if (layoutNode.nodes.has(Nodes.Semantics)) layoutNode else layoutNode.findClosestParentNode { it.nodes.has(Nodes.Semantics) } - val config = semanticsNode?.collapsedSemantics ?: return + val config = semanticsNode?.semanticsConfiguration ?: return if (!config.isMergingSemanticsOfDescendants) { semanticsNode .findClosestParentNode { - it.collapsedSemantics?.isMergingSemanticsOfDescendants == true + it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true } ?.let { semanticsNode = it } } @@ -3236,6 +2901,353 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo } } +private fun setTraversalValues( + currentSemanticsNodes: IntObjectMap, + idToBeforeMap: MutableIntIntMap, + idToAfterMap: MutableIntIntMap, + resources: Resources +) { + idToBeforeMap.clear() + idToAfterMap.clear() + + val hostSemanticsNode = + currentSemanticsNodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]?.semanticsNode!! + val hostLayoutIsRtl = hostSemanticsNode.isRtl + + val semanticsOrderList = + subtreeSortedByGeometryGrouping( + hostLayoutIsRtl, + listOf(hostSemanticsNode), + currentSemanticsNodes, + resources + ) + + // Iterate through our ordered list, and creating a mapping of current node to next node ID + // We'll later read through this and set traversal order with IdToBeforeMap + for (i in 1..semanticsOrderList.lastIndex) { + val prevId = semanticsOrderList[i - 1].id + val currId = semanticsOrderList[i].id + idToBeforeMap[prevId] = currId + idToAfterMap[currId] = prevId + } +} + +/** + * This function prepares a subtree for `sortByGeometryGroupings` by retrieving all non-container + * nodes and adding them to the list to be geometrically sorted. We recurse on containers (if they + * exist) and add their sorted children to an optional mapping. The list to be sorted and child + * mapping is passed into `sortByGeometryGroupings`. + */ +private fun subtreeSortedByGeometryGrouping( + layoutIsRtl: Boolean, + listToSort: List, + currentSemanticsNodes: IntObjectMap, + resources: Resources +): MutableList { + // This should be mapping of [containerID: listOfSortedChildren], only populated if there + // are container nodes in this level. If there are container nodes, `containerMapToChildren` + // would look like {containerId: [sortedChild, sortedChild], containerId: [sortedChild]} + val containerMapToChildren = mutableIntObjectMapOf>() + val geometryList = ArrayList() + + listToSort.fastForEach { node -> + geometryDepthFirstSearch( + node, + geometryList, + containerMapToChildren, + currentSemanticsNodes, + resources + ) + } + + return sortByGeometryGroupings(layoutIsRtl, geometryList, resources, containerMapToChildren) +} + +private fun geometryDepthFirstSearch( + currNode: SemanticsNode, + geometryList: ArrayList, + containerMapToChildren: MutableIntObjectMap>, + currentSemanticsNodes: IntObjectMap, + resources: Resources +) { + val currRTL = currNode.isRtl + // We only want to add children that are either traversalGroups or are + // screen reader focusable. The child must also be in the current pruned semantics tree. + val isTraversalGroup = + currNode.unmergedConfig.getOrElse(SemanticsProperties.IsTraversalGroup) { false } + + if ( + (isTraversalGroup || isScreenReaderFocusable(currNode, resources)) && + currentSemanticsNodes.containsKey(currNode.id) + ) { + geometryList.add(currNode) + } + if (isTraversalGroup) { + // Recurse and record the container's children, sorted + containerMapToChildren[currNode.id] = + subtreeSortedByGeometryGrouping( + currRTL, + currNode.children, + currentSemanticsNodes, + resources + ) + } else { + // Otherwise, continue adding children to the list that'll be sorted regardless of + // hierarchy + currNode.children.fastForEach { child -> + geometryDepthFirstSearch( + child, + geometryList, + containerMapToChildren, + currentSemanticsNodes, + resources + ) + } + } +} + +/** + * Returns the results of geometry groupings, which is determined from 1) grouping nodes into + * distinct, non-overlapping rows based on their top/bottom coordinates, then 2) sorting nodes + * within each row with the semantics comparator. + * + * This method approaches traversal order with more nuance than an approach considering only just + * hierarchy or only just an individual node's bounds. + * + * If [containerChildrenMapping] exists, there are additional children to add, as well as the sorted + * parent itself + */ +private fun sortByGeometryGroupings( + layoutIsRtl: Boolean, + parentListToSort: ArrayList, + resources: Resources, + containerChildrenMapping: MutableIntObjectMap> = + mutableIntObjectMapOf() +): MutableList { + // RowGroupings list consists of pairs, first = a rectangle of the bounds of the row + // and second = the list of nodes in that row + val rowGroupings = ArrayList>>(parentListToSort.size / 2) + + for (entryIndex in 0..parentListToSort.lastIndex) { + val currEntry = parentListToSort[entryIndex] + // If this is the first entry, or vertical groups don't overlap + if (entryIndex == 0 || !placedEntryRowOverlaps(rowGroupings, currEntry)) { + val newRect = currEntry.boundsInWindow + rowGroupings.add(Pair(newRect, mutableListOf(currEntry))) + } // otherwise, we've already iterated through, found and placed it in a matching group + } + + // Sort the rows from top to bottom + rowGroupings.sortWith(TopBottomBoundsComparator) + + val returnList = ArrayList() + val comparator = semanticComparators[if (layoutIsRtl) 0 else 1] + rowGroupings.fastForEach { row -> + // Sort each individual row's parent nodes + row.second.sortWith(comparator) + returnList.addAll(row.second) + } + + returnList.sortWith(UnmergedConfigComparator) + + var i = 0 + // Afterwards, go in and add the containers' children. + while (i <= returnList.lastIndex) { + val currNodeId = returnList[i].id + // If a parent node is a container, then add its children. + // Add all container's children after the container itself. + // Because we've already recursed on the containers children, the children should + // also be sorted by their traversal index + val containersChildrenList = containerChildrenMapping[currNodeId] + if (containersChildrenList != null) { + val containerIsScreenReaderFocusable = isScreenReaderFocusable(returnList[i], resources) + if (!containerIsScreenReaderFocusable) { + // Container is removed if it is not screenreader-focusable + returnList.removeAt(i) + } else { + // Increase counter if the container was not removed + i += 1 + } + // Add all the container's children and increase counter by the number of children + returnList.addAll(i, containersChildrenList) + i += containersChildrenList.size + } else { + // Advance to the next item + i += 1 + } + } + return returnList +} + +private fun isScreenReaderFocusable(node: SemanticsNode, resources: Resources): Boolean { + val nodeContentDescriptionOrNull = + node.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull() + val isSpeakingNode = + nodeContentDescriptionOrNull != null || + getInfoText(node) != null || + getInfoStateDescriptionOrNull(node, resources) != null || + getInfoIsCheckable(node) + + return !node.isHidden && + (node.unmergedConfig.isMergingSemanticsOfDescendants || + node.isUnmergedLeafNode && isSpeakingNode) +} + +private fun getInfoText(node: SemanticsNode): AnnotatedString? { + val editableTextToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.EditableText) + val textToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull() + return editableTextToAssign ?: textToAssign +} + +private fun getInfoStateDescriptionOrNull(node: SemanticsNode, resources: Resources): String? { + var stateDescription = node.unmergedConfig.getOrNull(SemanticsProperties.StateDescription) + val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState) + val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role) + + // Check toggle state and retrieve description accordingly + toggleState?.let { + when (it) { + ToggleableState.On -> { + // Unfortunately, talkback has a bug of using "checked", so we set state + // description here + if (role == Role.Switch && stateDescription == null) { + stateDescription = resources.getString(R.string.state_on) + } + } + ToggleableState.Off -> { + // Unfortunately, talkback has a bug of using "not checked", so we set state + // description here + if (role == Role.Switch && stateDescription == null) { + stateDescription = resources.getString(R.string.state_off) + } + } + ToggleableState.Indeterminate -> { + if (stateDescription == null) { + stateDescription = resources.getString(R.string.indeterminate) + } + } + } + } + + // Check Selected property and retrieve description accordingly + node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let { + if (role != Role.Tab) { + if (stateDescription == null) { + // If a radio entry (radio button + text) is selectable, it won't have the role + // RadioButton, so if we use info.isCheckable/info.isChecked, talkback will say + // "checked/not checked" instead "selected/note selected". + stateDescription = + if (it) { + resources.getString(R.string.selected) + } else { + resources.getString(R.string.not_selected) + } + } + } + } + + // Check if a node has progress bar range info and retrieve description accordingly + val rangeInfo = node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo) + rangeInfo?.let { + // let's set state description here and use state description change events. + // otherwise, we need to send out type_view_selected event, as the old android + // versions do. But the support for type_view_selected event for progress bars + // maybe deprecated in talkback in the future. + if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) { + if (stateDescription == null) { + val valueRange = rangeInfo.range + val progress = + (if (valueRange.endInclusive - valueRange.start == 0f) 0f + else + (rangeInfo.current - valueRange.start) / + (valueRange.endInclusive - valueRange.start)) + .fastCoerceIn(0f, 1f) + + // We only display 0% or 100% when it is exactly 0% or 100%. + val percent = + when (progress) { + 0f -> 0 + 1f -> 100 + else -> (progress * 100).fastRoundToInt().coerceIn(1, 99) + } + stateDescription = resources.getString(R.string.template_percent, percent) + } + } else if (stateDescription == null) { + stateDescription = resources.getString(R.string.in_progress) + } + } + + if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) { + stateDescription = createStateDescriptionForTextField(node, resources) + } + + return stateDescription +} + +/** + * Empty text field should not be ignored by the TB so we set a state description. When there is a + * speakable child, like a label or a placeholder text, setting this state description is redundant + */ +private fun createStateDescriptionForTextField(node: SemanticsNode, resources: Resources): String? { + val mergedConfig = node.copyWithMergingEnabled().config + val mergedNodeIsUnspeakable = + mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() && + mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() && + mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty() + return if (mergedNodeIsUnspeakable) resources.getString(R.string.state_empty) else null +} + +private fun getInfoIsCheckable(node: SemanticsNode): Boolean { + var isCheckable = false + val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState) + val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role) + + toggleState?.let { isCheckable = true } + + node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let { + if (role != Role.Tab) { + isCheckable = true + } + } + + return isCheckable +} + +// check to see if this entry overlaps with any groupings in rowGroupings +private fun placedEntryRowOverlaps( + rowGroupings: ArrayList>>, + node: SemanticsNode +): Boolean { + // Conversion to long is needed in order to utilize `until`, which has no float ver + val entryTopCoord = node.boundsInWindow.top + val entryBottomCoord = node.boundsInWindow.bottom + val entryIsEmpty = entryTopCoord >= entryBottomCoord + + for (currIndex in 0..rowGroupings.lastIndex) { + val currRect = rowGroupings[currIndex].first + val groupIsEmpty = currRect.top >= currRect.bottom + val groupOverlapsEntry = + !entryIsEmpty && + !groupIsEmpty && + max(entryTopCoord, currRect.top) < min(entryBottomCoord, currRect.bottom) + + // If it overlaps with this row group, update cover and add node + if (groupOverlapsEntry) { + val newRect = + currRect.intersect(0f, entryTopCoord, Float.POSITIVE_INFINITY, entryBottomCoord) + // Replace the cover rectangle, copying over the old list of nodes + rowGroupings[currIndex] = Pair(newRect, rowGroupings[currIndex].second) + // Add current node + rowGroupings[currIndex].second.add(node) + // We've found an overlapping group, return true + return true + } + } + + // If we've made it here, then there are no groups our entry overlaps with + return false +} + // TODO(mnuzen): Move common semantics logic into `SemanticsUtils` file to make a11y delegate // shorter and more readable. private fun SemanticsNode.enabled() = (!config.contains(SemanticsProperties.Disabled)) @@ -3264,12 +3276,12 @@ private fun SemanticsNode.excludeLineAndPageGranularities(): Boolean { val ancestor = layoutNode.findClosestParentNode { // looking for text field merging node - val ancestorSemanticsConfiguration = it.collapsedSemantics + val ancestorSemanticsConfiguration = it.semanticsConfiguration ancestorSemanticsConfiguration?.isMergingSemanticsOfDescendants == true && ancestorSemanticsConfiguration.contains(SemanticsProperties.EditableText) } return ancestor != null && - ancestor.collapsedSemantics?.getOrNull(SemanticsProperties.Focused) != true + ancestor.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused) != true } private fun AccessibilityAction<*>.accessibilityEquals(other: Any?): Boolean { diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidCompositionLocals.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidCompositionLocals.android.kt index 16452b744530a..8871d05e76f2a 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidCompositionLocals.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidCompositionLocals.android.kt @@ -95,6 +95,14 @@ internal fun ProvideAndroidCompositionLocals( } DisposableEffect(Unit) { onDispose { saveableStateRegistry.dispose() } } + val hapticFeedback = remember { + if (HapticDefaults.isPremiumVibratorEnabled(context)) { + DefaultHapticFeedback(owner.view) + } else { + NoHapticFeedback() + } + } + val imageVectorCache = obtainImageVectorCache(context, configuration) val resourceIdCache = obtainResourceIdCache(context) val scrollCaptureInProgress = @@ -109,6 +117,7 @@ internal fun ProvideAndroidCompositionLocals( LocalImageVectorCache provides imageVectorCache, LocalResourceIdCache provides resourceIdCache, LocalProvidableScrollCaptureInProgress provides scrollCaptureInProgress, + LocalHapticFeedback provides hapticFeedback, ) { ProvideCommonCompositionLocals(owner = owner, uriHandler = uriHandler, content = content) } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidTextToolbar.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidTextToolbar.android.kt index 4cee3f5bd594d..35c2a96dcacd1 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidTextToolbar.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidTextToolbar.android.kt @@ -38,13 +38,15 @@ internal class AndroidTextToolbar(private val view: View) : TextToolbar { onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, onCutRequested: (() -> Unit)?, - onSelectAllRequested: (() -> Unit)? + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)? ) { textActionModeCallback.rect = rect textActionModeCallback.onCopyRequested = onCopyRequested textActionModeCallback.onCutRequested = onCutRequested textActionModeCallback.onPasteRequested = onPasteRequested textActionModeCallback.onSelectAllRequested = onSelectAllRequested + textActionModeCallback.onAutofillRequested = onAutofillRequested if (actionMode == null) { status = TextToolbarStatus.Shown actionMode = @@ -62,6 +64,23 @@ internal class AndroidTextToolbar(private val view: View) : TextToolbar { } } + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)? + ) { + showMenu( + rect = rect, + onCopyRequested = onCopyRequested, + onPasteRequested = onPasteRequested, + onCutRequested = onCutRequested, + onSelectAllRequested = onSelectAllRequested, + onAutofillRequested = null + ) + } + override fun hide() { status = TextToolbarStatus.Hidden actionMode?.finish() diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidWindowInfo.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidWindowInfo.android.kt new file mode 100644 index 0000000000000..c83fc401d59b0 --- /dev/null +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidWindowInfo.android.kt @@ -0,0 +1,391 @@ +/* + * 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. + */ + +package androidx.compose.ui.platform + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import android.graphics.Point +import android.graphics.Rect +import android.os.Build +import android.view.Display +import android.view.DisplayCutout +import android.view.WindowManager +import androidx.annotation.RequiresApi +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.platform.WindowInfoImpl.Companion.GlobalKeyboardModifiers +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastRoundToInt +import java.lang.reflect.InvocationTargetException + +/** + * WindowInfo that only calculates [containerSize] if the property has been read, to avoid expensive + * size calculation when no one is reading the value. + */ +internal class LazyWindowInfo : WindowInfo { + private var onInitializeContainerSize: (() -> IntSize)? = null + private var _containerSize: MutableState? = null + + override var isWindowFocused: Boolean by mutableStateOf(false) + + override var keyboardModifiers: PointerKeyboardModifiers + get() = GlobalKeyboardModifiers.value + set(value) { + GlobalKeyboardModifiers.value = value + } + + inline fun updateContainerSizeIfObserved(calculateContainerSize: () -> IntSize) { + _containerSize?.let { it.value = calculateContainerSize() } + } + + fun setOnInitializeContainerSize(onInitializeContainerSize: (() -> IntSize)?) { + // If we have already initialized, no need to set a listener here + if (_containerSize == null) { + this.onInitializeContainerSize = onInitializeContainerSize + } + } + + override val containerSize: IntSize + get() { + if (_containerSize == null) { + val initialSize = onInitializeContainerSize?.invoke() ?: IntSize.Zero + _containerSize = mutableStateOf(initialSize) + onInitializeContainerSize = null + } + return _containerSize!!.value + } +} + +/** + * TODO: b/369334429 Temporary fork of WindowMetricsCalculator logic until b/360934048 and + * b/369170239 are resolved + */ +internal fun calculateWindowSize(androidComposeView: AndroidComposeView): IntSize { + val context = androidComposeView.context + val activity = context.findActivity() + if (activity != null) { + val bounds = BoundsHelper.getInstance().currentWindowBounds(activity) + return IntSize(width = bounds.width(), height = bounds.height()) + } else { + // Fallback behavior for views created with an applicationContext / other non-Activity host + val configuration = context.resources.configuration + val density = context.resources.displayMetrics.density + val width = (configuration.screenWidthDp * density).fastRoundToInt() + val height = (configuration.screenHeightDp * density).fastRoundToInt() + return IntSize(width = width, height = height) + } +} + +private tailrec fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> this.baseContext.findActivity() + else -> null + } + +private interface BoundsHelper { + /** Compute the current bounds for the given [Activity]. */ + fun currentWindowBounds(activity: Activity): Rect + + companion object { + fun getInstance(): BoundsHelper { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + BoundsHelperApi30Impl + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + BoundsHelperApi29Impl + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> { + BoundsHelperApi28Impl + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { + BoundsHelperApi24Impl + } + else -> { + BoundsHelperApi16Impl + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.R) +private object BoundsHelperApi30Impl : BoundsHelper { + override fun currentWindowBounds(activity: Activity): Rect { + val wm = activity.getSystemService(WindowManager::class.java) + return wm.currentWindowMetrics.bounds + } +} + +@RequiresApi(Build.VERSION_CODES.Q) +private object BoundsHelperApi29Impl : BoundsHelper { + + /** Computes the window bounds for [Build.VERSION_CODES.Q]. */ + @SuppressLint("BanUncheckedReflection", "BlockedPrivateApi") + override fun currentWindowBounds(activity: Activity): Rect { + var bounds: Rect + val config = activity.resources.configuration + try { + val windowConfigField = + Configuration::class.java.getDeclaredField("windowConfiguration") + windowConfigField.isAccessible = true + val windowConfig = windowConfigField[config] + val getBoundsMethod = windowConfig.javaClass.getDeclaredMethod("getBounds") + bounds = Rect(getBoundsMethod.invoke(windowConfig) as Rect) + } catch (e: Exception) { + when (e) { + is NoSuchFieldException, + is NoSuchMethodException, + is IllegalAccessException, + is InvocationTargetException -> { + // If reflection fails for some reason default to the P implementation which + // still has the ability to account for display cutouts. + bounds = BoundsHelperApi28Impl.currentWindowBounds(activity) + } + else -> throw e + } + } + return bounds + } +} + +@RequiresApi(Build.VERSION_CODES.P) +private object BoundsHelperApi28Impl : BoundsHelper { + + /** + * Computes the window bounds for [Build.VERSION_CODES.P]. + * + * NOTE: This method may result in incorrect values if the [android.content.res.Resources] value + * stored at 'navigation_bar_height' does not match the true navigation bar inset on the window. + */ + @SuppressLint("BanUncheckedReflection", "BlockedPrivateApi") + override fun currentWindowBounds(activity: Activity): Rect { + val bounds = Rect() + val config = activity.resources.configuration + try { + val windowConfigField = + Configuration::class.java.getDeclaredField("windowConfiguration") + windowConfigField.isAccessible = true + val windowConfig = windowConfigField[config] + + // In multi-window mode we'll use the WindowConfiguration#mBounds property which + // should match the window size. Otherwise we'll use the mAppBounds property and + // will adjust it below. + if (activity.isInMultiWindowMode) { + val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getBounds") + bounds.set((getAppBounds.invoke(windowConfig) as Rect)) + } else { + val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getAppBounds") + bounds.set((getAppBounds.invoke(windowConfig) as Rect)) + } + } catch (e: Exception) { + when (e) { + is NoSuchFieldException, + is NoSuchMethodException, + is IllegalAccessException, + is InvocationTargetException -> { + getRectSizeFromDisplay(activity, bounds) + } + else -> throw e + } + } + + val platformWindowManager = activity.windowManager + + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") val currentDisplay = platformWindowManager.defaultDisplay + val realDisplaySize = Point() + @Suppress("DEPRECATION") currentDisplay.getRealSize(realDisplaySize) + + if (!activity.isInMultiWindowMode) { + // The activity is not in multi-window mode. Check if the addition of the + // navigation bar size to mAppBounds results in the real display size and if so + // assume the nav bar height should be added to the result. + val navigationBarHeight = getNavigationBarHeight(activity) + if (bounds.bottom + navigationBarHeight == realDisplaySize.y) { + bounds.bottom += navigationBarHeight + } else if (bounds.right + navigationBarHeight == realDisplaySize.x) { + bounds.right += navigationBarHeight + } else if (bounds.left == navigationBarHeight) { + bounds.left = 0 + } + } + if ( + (bounds.width() < realDisplaySize.x || bounds.height() < realDisplaySize.y) && + !activity.isInMultiWindowMode + ) { + // If the corrected bounds are not the same as the display size and the activity is + // not in multi-window mode it is possible there are unreported cutouts inset-ing + // the window depending on the layoutInCutoutMode. Check for them here by getting + // the cutout from the display itself. + val displayCutout = getCutoutForDisplay(currentDisplay) + if (displayCutout != null) { + if (bounds.left == displayCutout.safeInsetLeft) { + bounds.left = 0 + } + if (realDisplaySize.x - bounds.right == displayCutout.safeInsetRight) { + bounds.right += displayCutout.safeInsetRight + } + if (bounds.top == displayCutout.safeInsetTop) { + bounds.top = 0 + } + if (realDisplaySize.y - bounds.bottom == displayCutout.safeInsetBottom) { + bounds.bottom += displayCutout.safeInsetBottom + } + } + } + return bounds + } +} + +@RequiresApi(Build.VERSION_CODES.N) +private object BoundsHelperApi24Impl : BoundsHelper { + + /** + * Computes the window bounds for platforms between [Build.VERSION_CODES.N] and + * [Build.VERSION_CODES.O_MR1], inclusive. + * + * NOTE: This method may result in incorrect values under the following conditions: + * * If the activity is in multi-window mode the origin of the returned bounds will always be + * anchored at (0, 0). + * * If the [android.content.res.Resources] value stored at 'navigation_bar_height' does not + * match the true navigation bar size the returned bounds will not take into account the + * navigation bar. + */ + override fun currentWindowBounds(activity: Activity): Rect { + val bounds = Rect() + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") val defaultDisplay = activity.windowManager.defaultDisplay + // [Display#getRectSize] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") defaultDisplay.getRectSize(bounds) + if (!activity.isInMultiWindowMode) { + // The activity is not in multi-window mode. Check if the addition of the + // navigation bar size to Display#getSize() results in the real display size and + // if so return this value. If not, return the result of Display#getSize(). + val realDisplaySize = Point() + @Suppress("DEPRECATION") defaultDisplay.getRealSize(realDisplaySize) + val navigationBarHeight = getNavigationBarHeight(activity) + if (bounds.bottom + navigationBarHeight == realDisplaySize.y) { + bounds.bottom += navigationBarHeight + } else if (bounds.right + navigationBarHeight == realDisplaySize.x) { + bounds.right += navigationBarHeight + } + } + return bounds + } +} + +private object BoundsHelperApi16Impl : BoundsHelper { + + /** + * Computes the window bounds for platforms between [Build.VERSION_CODES.JELLY_BEAN] and + * [Build.VERSION_CODES.M], inclusive. + * + * Given that multi-window mode isn't supported before N we simply return the real display size + * which should match the window size of a full-screen app. + */ + override fun currentWindowBounds(activity: Activity): Rect { + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") val defaultDisplay = activity.windowManager.defaultDisplay + val realDisplaySize = Point() + @Suppress("DEPRECATION") defaultDisplay.getRealSize(realDisplaySize) + val bounds = Rect() + if (realDisplaySize.x == 0 || realDisplaySize.y == 0) { + // [Display#getRectSize] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") defaultDisplay.getRectSize(bounds) + } else { + bounds.right = realDisplaySize.x + bounds.bottom = realDisplaySize.y + } + return bounds + } +} + +/** + * Returns the [android.content.res.Resources] value stored as 'navigation_bar_height'. + * + * Note: This is error-prone and is **not** the recommended way to determine the size of the + * overlapping region between the navigation bar and a given window. The best approach is to acquire + * the [android.view.WindowInsets]. + */ +private fun getNavigationBarHeight(context: Context): Int { + val resources = context.resources + val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") + return if (resourceId > 0) { + resources.getDimensionPixelSize(resourceId) + } else 0 +} + +private fun getRectSizeFromDisplay(activity: Activity, bounds: Rect) { + // [WindowManager#getDefaultDisplay] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") val defaultDisplay = activity.windowManager.defaultDisplay + // [Display#getRectSize] is deprecated but we have this for + // compatibility with older versions + @Suppress("DEPRECATION") defaultDisplay.getRectSize(bounds) +} + +/** + * Returns the [DisplayCutout] for the given display. Note that display cutout returned here is for + * the display and the insets provided are in the display coordinate system. + * + * @return the display cutout for the given display. + */ +@SuppressLint("BanUncheckedReflection") +@RequiresApi(Build.VERSION_CODES.P) +private fun getCutoutForDisplay(display: Display): DisplayCutout? { + var displayCutout: DisplayCutout? = null + try { + val displayInfoClass = Class.forName("android.view.DisplayInfo") + val displayInfoConstructor = displayInfoClass.getConstructor() + displayInfoConstructor.isAccessible = true + val displayInfo = displayInfoConstructor.newInstance() + val getDisplayInfoMethod = + display.javaClass.getDeclaredMethod("getDisplayInfo", displayInfo.javaClass) + getDisplayInfoMethod.isAccessible = true + getDisplayInfoMethod.invoke(display, displayInfo) + val displayCutoutField = displayInfo.javaClass.getDeclaredField("displayCutout") + displayCutoutField.isAccessible = true + val cutout = displayCutoutField[displayInfo] + if (cutout is DisplayCutout) { + displayCutout = cutout + } + } catch (e: Exception) { + when (e) { + is ClassNotFoundException, + is NoSuchMethodException, + is NoSuchFieldException, + is IllegalAccessException, + is InvocationTargetException, + is InstantiationException -> {} + else -> throw e + } + } + return displayCutout +} diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ComposeView.android.kt index d2ab361735ce4..0b66d5d726845 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ComposeView.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ComposeView.android.kt @@ -60,6 +60,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 init { clipChildren = false clipToPadding = false + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES } /** diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt index 51bdd813475a0..893ddde640a7c 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.graphics.GraphicsContext import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.ReusableGraphicsLayerScope import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.CanvasDrawScope @@ -39,7 +38,6 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.layer.setOutline -import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.internal.checkPreconditionNotNull import androidx.compose.ui.internal.requirePrecondition import androidx.compose.ui.layout.GraphicLayerInfo @@ -80,7 +78,6 @@ internal class GraphicsLayerOwnerLayer( private var mutatedFields: Int = 0 private var transformOrigin: TransformOrigin = TransformOrigin.Center private var outline: Outline? = null - private var tmpPath: Path? = null /** * Optional paint used when the RenderNode is rendered on a software backed canvas and is * somewhat transparent (i.e. alpha less than 1.0f) @@ -236,53 +233,13 @@ internal class GraphicsLayerOwnerLayer( private var drawnWithEnabledZ = false override fun drawLayer(canvas: Canvas, parentLayer: GraphicsLayer?) { - val androidCanvas = canvas.nativeCanvas - if (androidCanvas.isHardwareAccelerated) { - updateDisplayList() - drawnWithEnabledZ = graphicsLayer.shadowElevation > 0 - scope.drawContext.also { - it.canvas = canvas - it.graphicsLayer = parentLayer - } - scope.drawLayer(graphicsLayer) - } else { - // TODO ideally there should be some solution for drawing a layer on a software - // accelerated canvas built in right into GraphicsLayer, as this workaround is not - // solving all the use cases. For example, some one can use layers directly via - // drawWithContent { - // layer.record { - // this@drawWithContent.drawContent() - // } - // drawLayer(layer) - // } - // and if someone would try to draw the whole ComposeView on software accelerated - // canvas it will just crash saying RenderNodes can't be drawn into this canvas. - // This issue is tracked in b/333866398 - val left = graphicsLayer.topLeft.x.toFloat() - val top = graphicsLayer.topLeft.y.toFloat() - val right = left + size.width - val bottom = top + size.height - // If there is alpha applied, we must render into an offscreen buffer to - // properly blend the contents of this layer against the background content - if (graphicsLayer.alpha < 1.0f) { - val paint = - (softwareLayerPaint ?: Paint().also { softwareLayerPaint = it }).apply { - alpha = graphicsLayer.alpha - } - androidCanvas.saveLayer(left, top, right, bottom, paint.asFrameworkPaint()) - } else { - canvas.save() - } - // If we are software rendered we must translate the canvas based on the offset provided - // in the move call which operates directly on the RenderNode - canvas.translate(left, top) - canvas.concat(getMatrix()) - if (graphicsLayer.clip) { - clipManually(canvas) - } - drawBlock?.invoke(canvas, null) - canvas.restore() + updateDisplayList() + drawnWithEnabledZ = graphicsLayer.shadowElevation > 0 + scope.drawContext.also { + it.canvas = canvas + it.graphicsLayer = parentLayer } + scope.drawLayer(graphicsLayer) } override fun updateDisplayList() { @@ -454,27 +411,4 @@ internal class GraphicsLayerOwnerLayer( isIdentity = matrixCache.isIdentity() } } - - /** - * Manually clips the content of the RenderNodeLayer in the provided canvas. This is used only - * in software rendered use cases - */ - private fun clipManually(canvas: Canvas) { - if (graphicsLayer.clip) { - when (val outline = graphicsLayer.outline) { - is Outline.Rectangle -> { - canvas.clipRect(outline.rect) - } - is Outline.Rounded -> { - val path = tmpPath ?: Path().also { tmpPath = it } - path.reset() - path.addRoundRect(outline.roundRect) - canvas.clipPath(path) - } - is Outline.Generic -> { - canvas.clipPath(outline.path) - } - } - } - } } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/HapticFeedback.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/HapticFeedback.android.kt new file mode 100644 index 0000000000000..c3e33185fb9b7 --- /dev/null +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/HapticFeedback.android.kt @@ -0,0 +1,97 @@ +/* + * 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. + */ + +package androidx.compose.ui.platform + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.view.HapticFeedbackConstants +import android.view.View +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType + +/** + * Provide a default implementation of HapticFeedback to call through to the view's + * [performHapticFeedback] with the associated HapticFeedbackConstant. + * + * @param view The current view, used for forwarding haptic feedback requests. + */ +internal class DefaultHapticFeedback(private val view: View) : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + when (hapticFeedbackType) { + HapticFeedbackType.Confirm -> + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + HapticFeedbackType.ContextClick -> + view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + HapticFeedbackType.GestureEnd -> + view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END) + HapticFeedbackType.GestureThresholdActivate -> + view.performHapticFeedback(HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE) + HapticFeedbackType.LongPress -> + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + HapticFeedbackType.Reject -> view.performHapticFeedback(HapticFeedbackConstants.REJECT) + HapticFeedbackType.SegmentFrequentTick -> + view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK) + HapticFeedbackType.SegmentTick -> + view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_TICK) + HapticFeedbackType.TextHandleMove -> + view.performHapticFeedback(HapticFeedbackConstants.TEXT_HANDLE_MOVE) + HapticFeedbackType.ToggleOff -> + view.performHapticFeedback(HapticFeedbackConstants.TOGGLE_OFF) + HapticFeedbackType.ToggleOn -> + view.performHapticFeedback(HapticFeedbackConstants.TOGGLE_ON) + HapticFeedbackType.VirtualKey -> + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } +} + +/** Provide a no-op implementation of HapticFeedback */ +internal class NoHapticFeedback : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + // No-op + } +} + +/** Contains defaults for haptics functionality */ +internal object HapticDefaults { + /** + * Returns whether the device supports premium haptic feedback. + * + * @param context The current context for access to the Vibrator via System Service. + */ + fun isPremiumVibratorEnabled(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibrator = context.getSystemService(Vibrator::class.java) + + // NB whilst the 'areAllPrimitivesSupported' API needs R (API 30), we need S (API + // 31) so that PRIMITIVE_THUD is available. + if ( + vibrator.areAllPrimitivesSupported( + VibrationEffect.Composition.PRIMITIVE_CLICK, + VibrationEffect.Composition.PRIMITIVE_TICK, + VibrationEffect.Composition.PRIMITIVE_THUD + ) + ) { + return true + } + } + + return false + } +} diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/LayerMatrixCache.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/LayerMatrixCache.android.kt index 26d3523818534..7234b679d1864 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/LayerMatrixCache.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/LayerMatrixCache.android.kt @@ -35,7 +35,6 @@ internal class LayerMatrixCache( private val getMatrix: (target: T, matrix: AndroidMatrix) -> Unit ) { private var androidMatrixCache: AndroidMatrix? = null - private var previousAndroidMatrix: AndroidMatrix? = null private var matrixCache: Matrix = Matrix() private var inverseMatrixCache: Matrix = Matrix() @@ -74,16 +73,8 @@ internal class LayerMatrixCache( } val cachedMatrix = androidMatrixCache ?: AndroidMatrix().also { androidMatrixCache = it } - getMatrix(target, cachedMatrix) - - val prevMatrix = previousAndroidMatrix - if (prevMatrix == null || cachedMatrix != prevMatrix) { - matrix.setFrom(cachedMatrix) - androidMatrixCache = prevMatrix - previousAndroidMatrix = cachedMatrix - } - + matrix.setFrom(cachedMatrix) isDirty = false isIdentity = matrix.isIdentity() return matrix diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt index 127641a619915..71ab96813ee2b 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt @@ -396,6 +396,7 @@ internal class ViewLayer( mTransformOrigin = TransformOrigin.Center this.drawBlock = drawBlock this.invalidateParentLayer = invalidateParentLayer + isInvalidated = false } override fun transform(matrix: Matrix) { diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/actionmodecallback/TextActionModeCallback.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/actionmodecallback/TextActionModeCallback.android.kt index acfebedbbfc85..7020a32fdd2fb 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/actionmodecallback/TextActionModeCallback.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/actionmodecallback/TextActionModeCallback.android.kt @@ -16,10 +16,12 @@ package androidx.compose.ui.platform.actionmodecallback +import android.os.Build import android.view.ActionMode import android.view.Menu import android.view.MenuItem import androidx.annotation.VisibleForTesting +import androidx.compose.ui.R import androidx.compose.ui.geometry.Rect internal class TextActionModeCallback( @@ -28,7 +30,8 @@ internal class TextActionModeCallback( var onCopyRequested: (() -> Unit)? = null, var onPasteRequested: (() -> Unit)? = null, var onCutRequested: (() -> Unit)? = null, - var onSelectAllRequested: (() -> Unit)? = null + var onSelectAllRequested: (() -> Unit)? = null, + var onAutofillRequested: (() -> Unit)? = null ) { fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { requireNotNull(menu) { "onCreateActionMode requires a non-null menu" } @@ -38,6 +41,9 @@ internal class TextActionModeCallback( onPasteRequested?.let { addMenuItem(menu, MenuItemOption.Paste) } onCutRequested?.let { addMenuItem(menu, MenuItemOption.Cut) } onSelectAllRequested?.let { addMenuItem(menu, MenuItemOption.SelectAll) } + if (onAutofillRequested != null && Build.VERSION.SDK_INT >= 26) { + addMenuItem(menu, MenuItemOption.Autofill) + } return true } @@ -55,6 +61,7 @@ internal class TextActionModeCallback( MenuItemOption.Paste.id -> onPasteRequested?.invoke() MenuItemOption.Cut.id -> onCutRequested?.invoke() MenuItemOption.SelectAll.id -> onSelectAllRequested?.invoke() + MenuItemOption.Autofill.id -> onAutofillRequested?.invoke() else -> return false } mode?.finish() @@ -71,6 +78,7 @@ internal class TextActionModeCallback( addOrRemoveMenuItem(menu, MenuItemOption.Paste, onPasteRequested) addOrRemoveMenuItem(menu, MenuItemOption.Cut, onCutRequested) addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested) + addOrRemoveMenuItem(menu, MenuItemOption.Autofill, onAutofillRequested) } internal fun addMenuItem(menu: Menu, item: MenuItemOption) { @@ -91,7 +99,8 @@ internal enum class MenuItemOption(val id: Int) { Copy(0), Paste(1), Cut(2), - SelectAll(3); + SelectAll(3), + Autofill(4); val titleResource: Int get() = @@ -100,6 +109,12 @@ internal enum class MenuItemOption(val id: Int) { Paste -> android.R.string.paste Cut -> android.R.string.cut SelectAll -> android.R.string.selectAll + Autofill -> + if (Build.VERSION.SDK_INT <= 26) { + R.string.autofill + } else { + android.R.string.autofill + } } /** This item will be shown before all items that have order greater than this value. */ diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ColorResources.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ColorResources.android.kt index ea52c9a2575da..d6feb94d44234 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ColorResources.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ColorResources.android.kt @@ -16,14 +16,12 @@ package androidx.compose.ui.res -import android.content.Context -import android.os.Build import androidx.annotation.ColorRes -import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.core.content.res.ResourcesCompat /** * Load a color resource. @@ -35,16 +33,5 @@ import androidx.compose.ui.platform.LocalContext @ReadOnlyComposable fun colorResource(@ColorRes id: Int): Color { val context = LocalContext.current - return if (Build.VERSION.SDK_INT >= 23) { - ColorResourceHelper.getColor(context, id) - } else { - @Suppress("DEPRECATION") Color(context.resources.getColor(id)) - } -} - -@RequiresApi(23) -private object ColorResourceHelper { - fun getColor(context: Context, @ColorRes id: Int): Color { - return Color(context.resources.getColor(id, context.theme)) - } + return Color(ResourcesCompat.getColor(resources(), id, context.theme)) } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ImageResources.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ImageResources.android.kt index 90930e7908d38..313daf4ddeebb 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ImageResources.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/ImageResources.android.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext /** * Load an ImageBitmap from an image resource. @@ -51,9 +50,9 @@ fun ImageBitmap.Companion.imageResource(res: Resources, @DrawableRes id: Int): I */ @Composable fun ImageBitmap.Companion.imageResource(@DrawableRes id: Int): ImageBitmap { - val context = LocalContext.current + val resources = resources() val value = remember { TypedValue() } - context.resources.getValue(id, value, true) + resources.getValue(id, value, true) val key = value.string!!.toString() // image resource must have resource path. - return remember(key) { imageResource(context.resources, id) } + return remember(key) { imageResource(resources, id) } } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/PrimitiveResources.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/PrimitiveResources.android.kt index f10c3d996bf3b..8dd97841850a4 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/PrimitiveResources.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/PrimitiveResources.android.kt @@ -22,7 +22,6 @@ import androidx.annotation.DimenRes import androidx.annotation.IntegerRes import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp @@ -35,8 +34,7 @@ import androidx.compose.ui.unit.Dp @Composable @ReadOnlyComposable fun integerResource(@IntegerRes id: Int): Int { - val context = LocalContext.current - return context.resources.getInteger(id) + return resources().getInteger(id) } /** @@ -48,8 +46,7 @@ fun integerResource(@IntegerRes id: Int): Int { @Composable @ReadOnlyComposable fun integerArrayResource(@ArrayRes id: Int): IntArray { - val context = LocalContext.current - return context.resources.getIntArray(id) + return resources().getIntArray(id) } /** @@ -61,8 +58,7 @@ fun integerArrayResource(@ArrayRes id: Int): IntArray { @Composable @ReadOnlyComposable fun booleanResource(@BoolRes id: Int): Boolean { - val context = LocalContext.current - return context.resources.getBoolean(id) + return resources().getBoolean(id) } /** @@ -74,8 +70,7 @@ fun booleanResource(@BoolRes id: Int): Boolean { @Composable @ReadOnlyComposable fun dimensionResource(@DimenRes id: Int): Dp { - val context = LocalContext.current val density = LocalDensity.current - val pxValue = context.resources.getDimension(id) + val pxValue = resources().getDimension(id) return Dp(pxValue / density.density) } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapper.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapper.android.kt index dc8c5ed2f10d3..9c980bea4f015 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapper.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/NullableInputConnectionWrapper.android.kt @@ -109,12 +109,12 @@ private open class NullableInputConnectionWrapperApi21( override fun getTextAfterCursor(p0: Int, p1: Int): CharSequence? = delegate?.getTextAfterCursor(p0, p1) - override fun getSelectedText(p0: Int): CharSequence = delegate?.getSelectedText(p0) ?: "" + override fun getSelectedText(p0: Int): CharSequence? = delegate?.getSelectedText(p0) override fun getCursorCapsMode(p0: Int): Int = delegate?.getCursorCapsMode(p0) ?: 0 - override fun getExtractedText(p0: ExtractedTextRequest?, p1: Int): ExtractedText = - delegate?.getExtractedText(p0, p1) ?: ExtractedText() + override fun getExtractedText(p0: ExtractedTextRequest?, p1: Int): ExtractedText? = + delegate?.getExtractedText(p0, p1) override fun deleteSurroundingText(p0: Int, p1: Int): Boolean = delegate?.deleteSurroundingText(p0, p1) ?: false diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt index 2406bfad7990b..4c9ea23c51903 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt @@ -24,14 +24,11 @@ import android.view.ViewGroup.FOCUS_DOWN import android.view.ViewTreeObserver import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection.Companion.Exit +import androidx.compose.ui.focus.FocusEnterExitScope import androidx.compose.ui.focus.FocusOwner import androidx.compose.ui.focus.FocusProperties import androidx.compose.ui.focus.FocusPropertiesModifierNode -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusRequester.Companion.Cancel -import androidx.compose.ui.focus.FocusRequester.Companion.Default import androidx.compose.ui.focus.FocusTargetNode import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.performRequestFocus @@ -41,6 +38,7 @@ import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.Nodes import androidx.compose.ui.node.requireLayoutNode import androidx.compose.ui.node.requireOwner +import androidx.compose.ui.node.requireView import androidx.compose.ui.node.visitLocalDescendants import androidx.compose.ui.platform.InspectorInfo @@ -55,7 +53,7 @@ internal fun Modifier.focusInteropModifier(): Modifier = private class FocusTargetPropertiesNode : Modifier.Node(), FocusPropertiesModifierNode { override fun applyFocusProperties(focusProperties: FocusProperties) { - focusProperties.canFocus = node.isAttached && getView().hasFocusable() + focusProperties.canFocus = node.isAttached && getEmbeddedView().hasFocusable() } } @@ -66,62 +64,71 @@ private class FocusGroupPropertiesNode : View.OnAttachStateChangeListener { var focusedChild: View? = null - override fun applyFocusProperties(focusProperties: FocusProperties) { - focusProperties.canFocus = false - focusProperties.enter = ::onEnter - focusProperties.exit = ::onExit - } - - fun onEnter(focusDirection: FocusDirection): FocusRequester { + val onEnter: FocusEnterExitScope.() -> Unit = { // If this requestFocus is triggered by the embedded view getting focus, // then we don't perform this onEnter logic. - val embeddedView = getView() - if (embeddedView.isFocused || embeddedView.hasFocus()) return Default - - val focusOwner = requireOwner().focusOwner - val hostView = requireOwner() as View - - val targetViewFocused = - embeddedView.requestInteropFocus( - direction = focusDirection.toAndroidFocusDirection(), - rect = getCurrentlyFocusedRect(focusOwner, hostView, embeddedView) - ) - return if (targetViewFocused) Default else Cancel - } - - fun onExit(focusDirection: FocusDirection): FocusRequester { - val embeddedView = getView() - if (!embeddedView.hasFocus()) return Default - - val focusOwner = requireOwner().focusOwner - val hostView = requireOwner() as View - - // If the embedded view is not a view group, then we can safely exit this focus group. - if (embeddedView !is ViewGroup) { - check(hostView.requestFocus()) { "host view did not take focus" } - return Default + val embeddedView = getEmbeddedView() + if (!embeddedView.isFocused && !embeddedView.hasFocus()) { + val focusOwner = requireOwner().focusOwner + val hostView = requireView() + + val targetViewFocused = + embeddedView.requestInteropFocus( + direction = requestedFocusDirection.toAndroidFocusDirection(), + rect = getCurrentlyFocusedRect(focusOwner, hostView, embeddedView) + ) + if (!targetViewFocused) { + cancelFocus() + } } + } - val focusedRect = getCurrentlyFocusedRect(focusOwner, hostView, embeddedView) - val androidFocusDirection = focusDirection.toAndroidFocusDirection() ?: FOCUS_DOWN - - val nextView = - with(FocusFinder.getInstance()) { - if (focusedChild != null) { - findNextFocus(hostView as ViewGroup, focusedChild, androidFocusDirection) + val onExit: FocusEnterExitScope.() -> Unit = { + val embeddedView = getEmbeddedView() + if (embeddedView.hasFocus()) { + val focusOwner = requireOwner().focusOwner + val hostView = requireView() + + // If the embedded view is not a view group, then we can safely exit this focus group. + if (embeddedView !is ViewGroup) { + check(hostView.requestFocus()) { "host view did not take focus" } + } else { + val focusedRect = getCurrentlyFocusedRect(focusOwner, hostView, embeddedView) + val androidFocusDirection = + requestedFocusDirection.toAndroidFocusDirection() ?: FOCUS_DOWN + + val nextView = + with(FocusFinder.getInstance()) { + if (focusedChild != null) { + findNextFocus( + hostView as ViewGroup, + focusedChild, + androidFocusDirection + ) + } else { + findNextFocusFromRect( + hostView as ViewGroup, + focusedRect, + androidFocusDirection + ) + } + } + if (nextView != null && embeddedView.containsDescendant(nextView)) { + nextView.requestFocus(androidFocusDirection, focusedRect) + cancelFocus() } else { - findNextFocusFromRect(hostView as ViewGroup, focusedRect, androidFocusDirection) + check(hostView.requestFocus()) { "host view did not take focus" } } } - if (nextView != null && embeddedView.containsDescendant(nextView)) { - nextView.requestFocus(androidFocusDirection, focusedRect) - return Cancel - } else { - check(hostView.requestFocus()) { "host view did not take focus" } - return Default } } + override fun applyFocusProperties(focusProperties: FocusProperties) { + focusProperties.canFocus = false + focusProperties.onEnter = onEnter + focusProperties.onExit = onExit + } + private fun getFocusTargetOfEmbeddedViewWrapper(): FocusTargetNode { var foundFocusTargetOfFocusGroup = false visitLocalDescendants(Nodes.FocusTarget) { @@ -133,7 +140,7 @@ private class FocusGroupPropertiesNode : override fun onGlobalFocusChanged(oldFocus: View?, newFocus: View?) { if (requireLayoutNode().owner == null) return - val embeddedView = getView() + val embeddedView = getEmbeddedView() val focusOwner = requireOwner().focusOwner val hostView = requireOwner() val subViewLostFocus = @@ -175,11 +182,11 @@ private class FocusGroupPropertiesNode : override fun onAttach() { super.onAttach() - getView().addOnAttachStateChangeListener(this) + getEmbeddedView().addOnAttachStateChangeListener(this) } override fun onDetach() { - getView().removeOnAttachStateChangeListener(this) + getEmbeddedView().removeOnAttachStateChangeListener(this) focusedChild = null super.onDetach() } @@ -221,7 +228,7 @@ private object FocusTargetPropertiesElement : ModifierNodeElement= [Build.VERSION_CODES.VANILLA_ICE_CREAM], [decorFitsSystemWindows] can only be - * `false` and this property doesn't have any effect. + * [WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE] on [Build.VERSION_CODES.R] and below and + * [WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING] on [Build.VERSION_CODES.S] and above. + * [Window.isFloating] will be `false` when `decorFitsSystemWindows` is `false`. */ @Immutable actual class DialogProperties( @@ -225,16 +224,150 @@ interface DialogWindowProvider { @Suppress("ViewConstructor") private class DialogLayout(context: Context, override val window: Window) : - AbstractComposeView(context), DialogWindowProvider { + AbstractComposeView(context), DialogWindowProvider, OnApplyWindowInsetsListener { private var content: @Composable () -> Unit by mutableStateOf({}) - var usePlatformDefaultWidth = false - var decorFitsSystemWindows = false + private var usePlatformDefaultWidth = false + private var decorFitsSystemWindows = false + private var hasCalledSetLayout = false override var shouldCreateCompositionOnAttachedToWindow: Boolean = false private set + init { + ViewCompat.setOnApplyWindowInsetsListener(this, this) + ViewCompat.setWindowInsetsAnimationCallback( + this, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat = + insetValue(bounds) { l, t, r, b -> bounds.inset(Insets.of(l, t, r, b)) } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat = + insetValue(insets) { l, t, r, b -> insets.inset(l, t, r, b) } + } + ) + } + + fun updateProperties(usePlatformDefaultWidth: Boolean, decorFitsSystemWindows: Boolean) { + val callSetLayout = + !hasCalledSetLayout || + usePlatformDefaultWidth != this.usePlatformDefaultWidth || + decorFitsSystemWindows != this.decorFitsSystemWindows + this.usePlatformDefaultWidth = usePlatformDefaultWidth + this.decorFitsSystemWindows = decorFitsSystemWindows + + if (callSetLayout) { + val attrs = window.attributes + val measurementWidth = if (usePlatformDefaultWidth) WRAP_CONTENT else MATCH_PARENT + if (measurementWidth != attrs.width || !hasCalledSetLayout) { + // Always use WRAP_CONTENT for height. internalOnMeasure() will change + // it to MATCH_PARENT if it needs more height. If we use MATCH_PARENT here, + // and change to WRAP_CONTENT in internalOnMeasure(), the window size will + // be wrong on the first frame. + window.setLayout(measurementWidth, WRAP_CONTENT) + hasCalledSetLayout = true + } + } + } + + override fun internalOnMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val child = getChildAt(0) + if (child == null) { + super.internalOnMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val targetHeight = + if ( + heightMode == MeasureSpec.AT_MOST && + !usePlatformDefaultWidth && + !decorFitsSystemWindows && + window.attributes.height == WRAP_CONTENT + ) { + // Any size larger than the WRAP_CONTENT to test to see if this is full-screen + // content. + height + 1 + } else { + height + } + + val horizontalPadding = paddingLeft + paddingRight + val verticalPadding = paddingTop + paddingBottom + val remainingWidth = (width - horizontalPadding).fastCoerceAtLeast(0) + val remainingHeight = (targetHeight - verticalPadding).fastCoerceAtLeast(0) + + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val childWidthSpec = + if (widthMode == MeasureSpec.UNSPECIFIED) { + widthMeasureSpec + } else { + MeasureSpec.makeMeasureSpec(remainingWidth, MeasureSpec.AT_MOST) + } + val childHeightSpec = + if (heightMode == MeasureSpec.UNSPECIFIED) { + heightMeasureSpec + } else { + MeasureSpec.makeMeasureSpec(remainingHeight, MeasureSpec.AT_MOST) + } + child.measure(childWidthSpec, childHeightSpec) + + // respect passed dimensions + val measuredWidth = + when (widthMode) { + MeasureSpec.EXACTLY -> width + MeasureSpec.AT_MOST -> minOf(width, child.measuredWidth + horizontalPadding) + else -> child.measuredWidth + horizontalPadding + } + val measuredHeight = + when (heightMode) { + MeasureSpec.EXACTLY -> height + MeasureSpec.AT_MOST -> minOf(height, child.measuredHeight + verticalPadding) + else -> child.measuredHeight + verticalPadding + } + setMeasuredDimension(measuredWidth, measuredHeight) + + if ( + !usePlatformDefaultWidth && + !decorFitsSystemWindows && + child.measuredHeight + verticalPadding > height && + window.attributes.height == WRAP_CONTENT + ) { + // The size of the window is too small with WRAP_CONTENT for height. Change it + // to use MATCH_PARENT to give as much room as possible + window.setLayout(MATCH_PARENT, MATCH_PARENT) + } + } + + override fun internalOnLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + val child = getChildAt(0) ?: return + + // center content + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + val width = right - left + val height = bottom - top + val childWidth = child.measuredWidth + val childHeight = child.measuredHeight + + val extraWidth = width - childWidth - hPadding + val extraHeight = height - childHeight - vPadding + + val l = paddingLeft + (extraWidth / 2) + val t = paddingTop + (extraHeight / 2) + val r = l + childWidth + val b = t + childHeight + child.layout(l, t, r, b) + } + fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { setParentCompositionContext(parent) this.content = content @@ -242,16 +375,43 @@ private class DialogLayout(context: Context, override val window: Window) : createComposition() } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = + insetValue(insets) { l, t, r, b -> insets.inset(l, t, r, b) } + + private inline fun insetValue( + unchangedValue: T, + block: (left: Int, top: Int, right: Int, bottom: Int) -> T + ): T { + if (decorFitsSystemWindows) { + return unchangedValue + } + val child = getChildAt(0) + val left = maxOf(0, child.left) + val top = maxOf(0, child.top) + val right = maxOf(0, width - child.right) + val bottom = maxOf(0, height - child.bottom) + return if (left == 0 && top == 0 && right == 0 && bottom == 0) { + unchangedValue + } else { + block(left, top, right, bottom) + } + } + + fun isInsideContent(event: MotionEvent): Boolean { + val child = getChildAt(0) ?: return false + val left = left + child.left + val right = left + child.width + val top = top + child.top + val bottom = top + child.height + return event.x.roundToInt() in left..right && event.y.roundToInt() in top..bottom + } + @Composable override fun Content() { content() } } -private fun adjustedDecorFitsSystemWindows(dialogProperties: DialogProperties, context: Context) = - dialogProperties.decorFitsSystemWindows && - context.applicationInfo.targetSdkVersion < Build.VERSION_CODES.VANILLA_ICE_CREAM - private class DialogWrapper( private var onDismissRequest: () -> Unit, private var properties: DialogProperties, @@ -267,17 +427,14 @@ private class DialogWrapper( */ ContextThemeWrapper( composeView.context, - if (adjustedDecorFitsSystemWindows(properties, composeView.context)) { + if (properties.decorFitsSystemWindows) { R.style.DialogWindowTheme } else { R.style.FloatingDialogWindowTheme } ) ), - ViewRootForInspector, - OnApplyWindowInsetsListener, - OnLayoutChangeListener, - OnTouchListener { + ViewRootForInspector { private val dialogLayout: DialogLayout @@ -292,8 +449,9 @@ private class DialogWrapper( val window = window ?: error("Dialog has no window") window.requestFeature(Window.FEATURE_NO_TITLE) window.setBackgroundDrawableResource(android.R.color.transparent) - val decorFitsSystemWindows = adjustedDecorFitsSystemWindows(properties, context) - WindowCompat.setDecorFitsSystemWindows(window, decorFitsSystemWindows) + WindowCompat.setDecorFitsSystemWindows(window, properties.decorFitsSystemWindows) + window.setGravity(Gravity.CENTER) + dialogLayout = DialogLayout(context, window).apply { // Set unique id for AbstractComposeView. This allows state restoration for the @@ -334,56 +492,8 @@ private class DialogWrapper( // Turn of all clipping so shadows can be drawn outside the window (window.decorView as? ViewGroup)?.disableClipping() - // Center the ComposeView in a FrameLayout - val frameLayout = FrameLayout(context) - frameLayout.addView( - dialogLayout, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ) - .also { it.gravity = Gravity.CENTER } - ) - frameLayout.clipChildren = false - ViewCompat.setOnApplyWindowInsetsListener(frameLayout, this) - ViewCompat.setWindowInsetsAnimationCallback( - frameLayout, - object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { - private inline fun insetValue( - unchangedValue: T, - block: (left: Int, top: Int, right: Int, bottom: Int) -> T - ): T { - if (properties.decorFitsSystemWindows) { - return unchangedValue - } - val left = maxOf(0, dialogLayout.left) - val top = maxOf(0, dialogLayout.top) - val right = maxOf(0, frameLayout.width - dialogLayout.right) - val bottom = maxOf(0, frameLayout.height - dialogLayout.bottom) - return if (left == 0 && top == 0 && right == 0 && bottom == 0) { - unchangedValue - } else { - block(left, top, right, bottom) - } - } - - override fun onStart( - animation: WindowInsetsAnimationCompat, - bounds: WindowInsetsAnimationCompat.BoundsCompat - ): WindowInsetsAnimationCompat.BoundsCompat = - insetValue(bounds) { l, t, r, b -> bounds.inset(Insets.of(l, t, r, b)) } - - override fun onProgress( - insets: WindowInsetsCompat, - runningAnimations: MutableList - ): WindowInsetsCompat = - insetValue(insets) { l, t, r, b -> insets.inset(l, t, r, b) } - } - ) - dialogLayout.addOnLayoutChangeListener(this) - frameLayout.addOnLayoutChangeListener(this) - setContentView(frameLayout) + setContentView(dialogLayout) dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner()) dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner()) dialogLayout.setViewTreeSavedStateRegistryOwner( @@ -454,12 +564,12 @@ private class DialogWrapper( this.properties = properties setSecurePolicy(properties.securePolicy) setLayoutDirection(layoutDirection) - dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth - val decorFitsSystemWindows = adjustedDecorFitsSystemWindows(properties, context) - dialogLayout.decorFitsSystemWindows = decorFitsSystemWindows + val decorFitsSystemWindows = properties.decorFitsSystemWindows + dialogLayout.updateProperties( + usePlatformDefaultWidth = properties.usePlatformDefaultWidth, + decorFitsSystemWindows = decorFitsSystemWindows + ) setCanceledOnTouchOutside(properties.dismissOnClickOutside) - val frameLayout = dialogLayout.parent as View - frameLayout.setOnTouchListener(if (properties.dismissOnClickOutside) this else null) val window = window if (window != null) { val softInput = @@ -471,29 +581,6 @@ private class DialogWrapper( else -> WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING } window.setSoftInputMode(softInput) - val attrs = window.attributes - val measurementWidth = - if (properties.usePlatformDefaultWidth) { - WindowManager.LayoutParams.WRAP_CONTENT - } else { - WindowManager.LayoutParams.MATCH_PARENT - } - val measurementHeight = - if (properties.usePlatformDefaultWidth || decorFitsSystemWindows) { - WindowManager.LayoutParams.WRAP_CONTENT - } else { - WindowManager.LayoutParams.MATCH_PARENT - } - if ( - attrs.width != measurementWidth || - attrs.height != measurementHeight || - attrs.gravity != Gravity.CENTER - ) { - attrs.width = measurementWidth - attrs.height = measurementHeight - attrs.gravity = Gravity.CENTER - window.attributes = attrs - } } } @@ -502,9 +589,10 @@ private class DialogWrapper( } override fun onTouchEvent(event: MotionEvent): Boolean { - val result = super.onTouchEvent(event) - if (result && properties.dismissOnClickOutside) { + var result = super.onTouchEvent(event) + if (properties.dismissOnClickOutside && !dialogLayout.isInsideContent(event)) { onDismissRequest() + result = true } return result @@ -514,49 +602,6 @@ private class DialogWrapper( // Prevents the dialog from dismissing itself return } - - override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { - if (properties.decorFitsSystemWindows) { - return insets - } - val left = maxOf(0, dialogLayout.left) - val top = maxOf(0, dialogLayout.top) - val right = maxOf(0, v.width - dialogLayout.right) - val bottom = maxOf(0, v.height - dialogLayout.bottom) - return insets.inset(left, top, right, bottom) - } - - override fun onLayoutChange( - v: View, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int - ) { - v.requestApplyInsets() - } - - override fun onTouch(v: View, event: MotionEvent): Boolean { - // This handler only set when properties.dismissOnClickOutside is true - if (event.actionMasked == MotionEvent.ACTION_UP) { - val x = event.x.roundToInt() - val y = event.y.roundToInt() - val insideContent = - x in dialogLayout.left..dialogLayout.right && - y in dialogLayout.top..dialogLayout.bottom - if (!insideContent) { - onDismissRequest() - return true - } - } - // We must always accept the ACTION_DOWN or else we don't receive the rest of the - // event stream. - return event.actionMasked == MotionEvent.ACTION_DOWN - } } @Composable diff --git a/compose/ui/ui/src/androidMain/res/values/strings.xml b/compose/ui/ui/src/androidMain/res/values/strings.xml index 3de8a2258e9d5..6c13938750b26 100644 --- a/compose/ui/ui/src/androidMain/res/values/strings.xml +++ b/compose/ui/ui/src/androidMain/res/values/strings.xml @@ -57,4 +57,6 @@ "Range end" Alert + + Autofill diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/autofill/AndroidContentTypeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/autofill/AndroidContentTypeTest.kt index a831af707083b..b0bca0475f9dd 100644 --- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/autofill/AndroidContentTypeTest.kt +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/autofill/AndroidContentTypeTest.kt @@ -97,191 +97,216 @@ import org.junit.runners.JUnit4 class AndroidContentTypeTest { @Test fun emailAddress() { - assertThat(EmailAddress).isEqualTo(ContentType.from(AUTOFILL_HINT_EMAIL_ADDRESS)) + assertThat(EmailAddress.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_EMAIL_ADDRESS).contentHints) } @Test fun username() { - assertThat(Username).isEqualTo(ContentType.from(AUTOFILL_HINT_USERNAME)) + assertThat(Username.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_USERNAME).contentHints) } @Test fun password() { - assertThat(Password).isEqualTo(ContentType.from(AUTOFILL_HINT_PASSWORD)) + assertThat(Password.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PASSWORD).contentHints) } @Test fun newUsername() { - assertThat(NewUsername).isEqualTo(ContentType.from(AUTOFILL_HINT_NEW_USERNAME)) + assertThat(NewUsername.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_NEW_USERNAME).contentHints) } @Test fun newPassword() { - assertThat(NewPassword).isEqualTo(ContentType.from(AUTOFILL_HINT_NEW_PASSWORD)) + assertThat(NewPassword.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_NEW_PASSWORD).contentHints) } @Test fun postalAddress() { - assertThat(PostalAddress).isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_ADDRESS)) + assertThat(PostalAddress.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_ADDRESS).contentHints) } @Test fun postalCode() { - assertThat(PostalCode).isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_CODE)) + assertThat(PostalCode.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_CODE).contentHints) } @Test fun creditCardNumber() { - assertThat(CreditCardNumber).isEqualTo(ContentType.from(AUTOFILL_HINT_CREDIT_CARD_NUMBER)) + assertThat(CreditCardNumber.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_CREDIT_CARD_NUMBER).contentHints) } @Test fun creditCardSecurityCode() { - assertThat(CreditCardSecurityCode) - .isEqualTo(ContentType.from(AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE)) + assertThat(CreditCardSecurityCode.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE).contentHints) } @Test fun creditCardExpirationDate() { - assertThat(CreditCardExpirationDate) - .isEqualTo(ContentType.from(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE)) + assertThat(CreditCardExpirationDate.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE).contentHints) } @Test fun creditCardExpirationMonth() { - assertThat(CreditCardExpirationMonth) - .isEqualTo(ContentType.from(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH)) + assertThat(CreditCardExpirationMonth.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH).contentHints) } @Test fun creditCardExpirationYear() { - assertThat(CreditCardExpirationYear) - .isEqualTo(ContentType.from(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR)) + assertThat(CreditCardExpirationYear.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR).contentHints) } @Test fun creditCardExpirationDay() { - assertThat(CreditCardExpirationDay) - .isEqualTo(ContentType.from(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY)) + assertThat(CreditCardExpirationDay.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY).contentHints) } @Test fun addressCountry() { - assertThat(AddressCountry).isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY)) + assertThat(AddressCountry.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY).contentHints) } @Test fun addressRegion() { - assertThat(AddressRegion).isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_ADDRESS_REGION)) + assertThat(AddressRegion.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_ADDRESS_REGION).contentHints) } @Test fun addressLocality() { - assertThat(AddressLocality) - .isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY)) + assertThat(AddressLocality.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY).contentHints) } @Test fun addressStreet() { - assertThat(AddressStreet) - .isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS)) + assertThat(AddressStreet.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS).contentHints) } @Test fun addressAuxiliaryDetails() { - assertThat(AddressAuxiliaryDetails) - .isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_ADDRESS)) + assertThat(AddressAuxiliaryDetails.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_ADDRESS).contentHints) } @Test fun postalCodeExtended() { - assertThat(PostalCodeExtended) - .isEqualTo(ContentType.from(AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_POSTAL_CODE)) + assertThat(PostalCodeExtended.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_POSTAL_CODE).contentHints) } @Test fun personFullName() { - assertThat(PersonFullName).isEqualTo(ContentType.from(AUTOFILL_HINT_PERSON_NAME)) + assertThat(PersonFullName.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PERSON_NAME).contentHints) } @Test fun personFirstName() { - assertThat(PersonFirstName).isEqualTo(ContentType.from(AUTOFILL_HINT_PERSON_NAME_GIVEN)) + assertThat(PersonFirstName.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PERSON_NAME_GIVEN).contentHints) } @Test fun personLastName() { - assertThat(PersonLastName).isEqualTo(ContentType.from(AUTOFILL_HINT_PERSON_NAME_FAMILY)) + assertThat(PersonLastName.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PERSON_NAME_FAMILY).contentHints) } @Test fun personMiddleName() { - assertThat(PersonMiddleName).isEqualTo(ContentType.from(AUTOFILL_HINT_PERSON_NAME_MIDDLE)) + assertThat(PersonMiddleName.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PERSON_NAME_MIDDLE).contentHints) } @Test fun personMiddleInitial() { - assertThat(PersonMiddleInitial) - .isEqualTo(ContentType.from(AUTOFILL_HINT_PERSON_NAME_MIDDLE_INITIAL)) + assertThat(PersonMiddleInitial.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PERSON_NAME_MIDDLE_INITIAL).contentHints) } @Test fun personNamePrefix() { - assertThat(PersonNamePrefix).isEqualTo(ContentType.from(AUTOFILL_HINT_PERSON_NAME_PREFIX)) + assertThat(PersonNamePrefix.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PERSON_NAME_PREFIX).contentHints) } @Test fun personNameSuffix() { - assertThat(PersonNameSuffix).isEqualTo(ContentType.from(AUTOFILL_HINT_PERSON_NAME_SUFFIX)) + assertThat(PersonNameSuffix.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PERSON_NAME_SUFFIX).contentHints) } @Test fun phoneNumber() { - assertThat(PhoneNumber).isEqualTo(ContentType.from(AUTOFILL_HINT_PHONE_NUMBER)) + assertThat(PhoneNumber.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PHONE_NUMBER).contentHints) } @Test fun phoneNumberDevice() { - assertThat(PhoneNumberDevice).isEqualTo(ContentType.from(AUTOFILL_HINT_PHONE_NUMBER_DEVICE)) + assertThat(PhoneNumberDevice.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PHONE_NUMBER_DEVICE).contentHints) } @Test fun phoneCountryCode() { - assertThat(PhoneCountryCode).isEqualTo(ContentType.from(AUTOFILL_HINT_PHONE_COUNTRY_CODE)) + assertThat(PhoneCountryCode.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PHONE_COUNTRY_CODE).contentHints) } @Test fun phoneNumberNational() { - assertThat(PhoneNumberNational).isEqualTo(ContentType.from(AUTOFILL_HINT_PHONE_NATIONAL)) + assertThat(PhoneNumberNational.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_PHONE_NATIONAL).contentHints) } @Test fun gender() { - assertThat(Gender).isEqualTo(ContentType.from(AUTOFILL_HINT_GENDER)) + assertThat(Gender.contentHints).isEqualTo(ContentType(AUTOFILL_HINT_GENDER).contentHints) } @Test fun birthDateFull() { - assertThat(BirthDateFull).isEqualTo(ContentType.from(AUTOFILL_HINT_BIRTH_DATE_FULL)) + assertThat(BirthDateFull.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_BIRTH_DATE_FULL).contentHints) } @Test fun birthDateDay() { - assertThat(BirthDateDay).isEqualTo(ContentType.from(AUTOFILL_HINT_BIRTH_DATE_DAY)) + assertThat(BirthDateDay.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_BIRTH_DATE_DAY).contentHints) } @Test fun birthDateMonth() { - assertThat(BirthDateMonth).isEqualTo(ContentType.from(AUTOFILL_HINT_BIRTH_DATE_MONTH)) + assertThat(BirthDateMonth.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_BIRTH_DATE_MONTH).contentHints) } @Test fun birthDateYear() { - assertThat(BirthDateYear).isEqualTo(ContentType.from(AUTOFILL_HINT_BIRTH_DATE_YEAR)) + assertThat(BirthDateYear.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_BIRTH_DATE_YEAR).contentHints) } @Test fun smsOTPCode() { - assertThat(SmsOtpCode).isEqualTo(ContentType.from(AUTOFILL_HINT_SMS_OTP)) + assertThat(SmsOtpCode.contentHints) + .isEqualTo(ContentType(AUTOFILL_HINT_SMS_OTP).contentHints) } } diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/HitTestTouchBoundsExpansionTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/HitTestTouchBoundsExpansionTest.kt new file mode 100644 index 0000000000000..b433c7627b7cd --- /dev/null +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/HitTestTouchBoundsExpansionTest.kt @@ -0,0 +1,411 @@ +/* + * 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. + */ + +package androidx.compose.ui.node + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class HitTestTouchBoundsExpansionTest { + + @Test + fun hitTest_touchBoundsExpansion_expandedBoundsForStylus() { + // Density is 1f by default so 1dp equals to 1px in the tests. + val pointerInputModifier1 = + FakePointerInputModifierNode(touchBoundsExpansion = TouchBoundsExpansion(1, 2, 3, 4)) + + val outerNode = + LayoutNode(0, 0, 100, 100) { + childNode(10, 10, 20, 20, pointerInputModifier1.toModifier()) + } + + assertTouchBounds( + pointerInputModifier1, + PointerType.Stylus, + outerNode, + Rect(9f, 8f, 23f, 24f) + ) + assertTouchBounds( + pointerInputModifier1, + PointerType.Eraser, + outerNode, + Rect(9f, 8f, 23f, 24f) + ) + } + + @Test + fun hitTest_touchBoundsExpansion_notExpandedBoundsForMouseTouchUnknown() { + // Density is 1f by default so 1dp equals to 1px in the tests. + val pointerInputModifier1 = + FakePointerInputModifierNode(touchBoundsExpansion = TouchBoundsExpansion(1, 2, 3, 4)) + + val outerNode = + LayoutNode(0, 0, 100, 100) { + childNode(10, 10, 20, 20, pointerInputModifier1.toModifier()) + } + + assertTouchBounds( + pointerInputModifier1, + PointerType.Unknown, + outerNode, + Rect(10f, 10f, 20f, 20f) + ) + assertTouchBounds( + pointerInputModifier1, + PointerType.Mouse, + outerNode, + Rect(10f, 10f, 20f, 20f) + ) + assertTouchBounds( + pointerInputModifier1, + PointerType.Touch, + outerNode, + Rect(10f, 10f, 20f, 20f) + ) + } + + @Test + fun hitTest_expandedBounds_rtlLayout() { + // Density is 1f by default so 1dp equals to 1px in the tests. + val pointerInputModifier1 = + FakePointerInputModifierNode(touchBoundsExpansion = TouchBoundsExpansion(1, 2, 3, 4)) + + val outerNode = + LayoutNode(0, 0, 100, 100) { + childNode( + 10, + 10, + 20, + 20, + pointerInputModifier1.toModifier(), + layoutDirection = LayoutDirection.Rtl + ) + } + + // start is applied on left and end is applied on right. + assertTouchBounds( + pointerInputModifier1, + PointerType.Stylus, + outerNode, + Rect(7f, 8f, 21f, 24f) + ) + } + + @Test + fun hitTest_expandedBounds_absolute_rtlLayout() { + // Density is 1f by default so 1dp equals to 1px in the tests. + val pointerInputModifier1 = + FakePointerInputModifierNode( + touchBoundsExpansion = TouchBoundsExpansion.Absolute(1, 2, 3, 4) + ) + + val outerNode = + LayoutNode(0, 0, 100, 100) { + childNode( + 10, + 10, + 20, + 20, + pointerInputModifier1.toModifier(), + layoutDirection = LayoutDirection.Rtl + ) + } + + assertTouchBounds( + pointerInputModifier1, + PointerType.Stylus, + outerNode, + Rect(9f, 8f, 23f, 24f) + ) + } + + @Test + fun hitTest_expandedBounds_largerThanParentBounds() { + // Density is 1f by default so 1dp equals to 1px in the tests. + val pointerInputModifier1 = + FakePointerInputModifierNode(touchBoundsExpansion = TouchBoundsExpansion(1, 2, 3, 4)) + + val outerNode = + LayoutNode(0, 0, 20, 20) { + childNode(10, 10, 20, 20, pointerInputModifier1.toModifier()) + } + + assertTouchBounds( + pointerInputModifier1, + PointerType.Stylus, + outerNode, + Rect(9f, 8f, 23f, 24f) + ) + } + + @Test + fun hitTest_expandedBounds_parentCantInterceptOutOfBoundsChildEvent() { + // Density is 1f by default so 1dp equals to 1px in the tests. + + val pointerInputModifierParent = + FakePointerInputModifierNode(interceptOutOfBoundsChildEvents = true) + + val pointerInputModifier = + FakePointerInputModifierNode(touchBoundsExpansion = TouchBoundsExpansion(1, 2, 3, 4)) + + val outerNode = + LayoutNode(0, 0, 20, 20, pointerInputModifierParent.toModifier()) { + childNode(30, 30, 40, 40, pointerInputModifier.toModifier()) + } + + // The pointer hits the childNode's expanded touch bounds, the parent can't intercept + // the event. + assertThat(outerNode.hitTest(Offset(29f, 28f))).isEqualTo(listOf(pointerInputModifier)) + + // The pointer directly hits the childNode, the parent can intercept the event. + assertThat(outerNode.hitTest(Offset(35f, 35f))) + .isEqualTo(listOf(pointerInputModifierParent, pointerInputModifier)) + } + + @Test + fun hitTest_expandedBounds_overlapsWithMinimumTouchTargetBounds_expandedBoundsWin() { + // Density is 1f by default so 1dp equals to 1px in the tests. + + val pointerInputModifier1 = + FakePointerInputModifierNode( + touchBoundsExpansion = TouchBoundsExpansion(10, 10, 10, 10) + ) + + val pointerInputModifier2 = FakePointerInputModifierNode() + + // Both nodes has it's touch bounds expanded by 10.dp. + val outerNode = + LayoutNode(0, 0, 20, 20) { + childNode(0, 0, 10, 10, pointerInputModifier1.toModifier()) + childNode( + 0, + 20, + 10, + 30, + pointerInputModifier2.toModifier(), + minimumTouchTargetSize = DpSize(30.dp, 30.dp) + ) + } + + // The pointer hits the overlapping area, the node with expanded touch bounds has the event. + assertThat(outerNode.hitTest(Offset(5f, 15f))).isEqualTo(listOf(pointerInputModifier1)) + } + + @Test + fun hitTest_expandedBounds_overlapsWithDirectHit_DirectHitWin() { + // Density is 1f by default so 1dp equals to 1px in the tests. + + val pointerInputModifier1 = + FakePointerInputModifierNode( + touchBoundsExpansion = TouchBoundsExpansion(10, 10, 10, 10) + ) + + val pointerInputModifier2 = FakePointerInputModifierNode() + + // Both nodes has it's touch bounds expanded by 10.dp. + val outerNode = + LayoutNode(0, 0, 20, 20) { + childNode(0, 0, 10, 10, pointerInputModifier1.toModifier()) + childNode(0, 10, 10, 20, pointerInputModifier2.toModifier()) + } + + // The pointer hits the overlapping area, the node with expanded touch bounds has the event. + assertThat(outerNode.hitTest(Offset(5f, 15f))).isEqualTo(listOf(pointerInputModifier2)) + } + + @Test + fun hitTest_expandedBounds_overlaps_shareEvents() { + // Density is 1f by default so 1dp equals to 1px in the tests. + + val pointerInputModifier1 = + FakePointerInputModifierNode( + touchBoundsExpansion = TouchBoundsExpansion(10, 10, 10, 10) + ) + + val pointerInputModifier2 = + FakePointerInputModifierNode( + touchBoundsExpansion = TouchBoundsExpansion(10, 10, 10, 10) + ) + + // Both nodes has it's touch bounds expanded by 10.dp. + val outerNode = + LayoutNode(0, 0, 20, 20) { + childNode(0, 0, 10, 10, pointerInputModifier1.toModifier()) + childNode(0, 20, 10, 30, pointerInputModifier2.toModifier()) + } + + // The pointer hits the expanded touch bounds' overlapping area, the pointer is shared. + // PointerInputModifier2 is hit first because it's attached on the newly added child with + // higher z index. + assertThat(outerNode.hitTest(Offset(5f, 15f))) + .isEqualTo(listOf(pointerInputModifier2, pointerInputModifier1)) + } + + @Test + fun hitTest_expandedBounds_applyOnPointerInputModifierLevel() { + // Density is 1f by default so 1dp equals to 1px in the tests. + val pointerInputModifier1 = + FakePointerInputModifierNode(touchBoundsExpansion = TouchBoundsExpansion(1, 2, 3, 4)) + val pointerInputModifier2 = FakePointerInputModifierNode() + + val outerNode = + LayoutNode(0, 0, 20, 20) { + childNode( + 10, + 10, + 20, + 20, + pointerInputModifier1.toModifier().then(pointerInputModifier2.toModifier()) + ) + } + + // Hit in expanded touch bounds, only pointerInputModifier1 is hit + assertTouchBounds( + pointerInputModifier1, + PointerType.Stylus, + outerNode, + Rect(9f, 8f, 23f, 24f) + ) + val hit = outerNode.hitTest(Offset(15f, 15f)) + // Hit inside the node, both modifiers are hit + assertThat(hit).isEqualTo(listOf(pointerInputModifier1, pointerInputModifier2)) + } + + private fun assertTouchBounds( + pointerInputModifierNode: PointerInputModifierNode, + pointerType: PointerType = PointerType.Stylus, + layoutNode: LayoutNode, + expectedBounds: Rect + ) { + assertThat(layoutNode.hitTest(expectedBounds.topLeft, pointerType)) + .isEqualTo(listOf(pointerInputModifierNode)) + + // Right edge is not inclusive, minus 0.01f here. + assertThat(layoutNode.hitTest(expectedBounds.topRight - Offset(0.01f, 0f), pointerType)) + .isEqualTo(listOf(pointerInputModifierNode)) + + // Bottom edge is not inclusive, minus 0.01f here. + assertThat(layoutNode.hitTest(expectedBounds.bottomLeft - Offset(0f, 0.01f), pointerType)) + .isEqualTo(listOf(pointerInputModifierNode)) + + // Bottom and right edge are not inclusive, minus 0.01f here. + assertThat( + layoutNode.hitTest(expectedBounds.bottomRight - Offset(0.01f, 0.01f), pointerType) + ) + .isEqualTo(listOf(pointerInputModifierNode)) + + // Also assert that the touch is exactly the expected bounds. + assertThat(layoutNode.hitTest(expectedBounds.topLeft - Offset(0.01f, 0.01f), pointerType)) + .isEmpty() + assertThat(layoutNode.hitTest(expectedBounds.topRight, pointerType)).isEmpty() + assertThat(layoutNode.hitTest(expectedBounds.bottomLeft, pointerType)).isEmpty() + assertThat(layoutNode.hitTest(expectedBounds.bottomRight, pointerType)).isEmpty() + } +} + +internal fun LayoutNode( + left: Int, + top: Int, + right: Int, + bottom: Int, + modifier: Modifier = Modifier, + minimumTouchTargetSize: DpSize = DpSize.Zero, + block: LayoutNode.() -> Unit +): LayoutNode { + val root = + LayoutNode(left, top, right, bottom, modifier, minimumTouchTargetSize).apply { + attach(MockOwner()) + } + + block.invoke(root) + return root +} + +internal fun LayoutNode.childNode( + left: Int, + top: Int, + right: Int, + bottom: Int, + modifier: Modifier = Modifier, + minimumTouchTargetSize: DpSize = DpSize.Zero, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, + block: LayoutNode.() -> Unit = {} +): LayoutNode { + val layoutNode = + LayoutNode(left, top, right, bottom, modifier, minimumTouchTargetSize, layoutDirection) + add(layoutNode) + layoutNode.onNodePlaced() + block.invoke(layoutNode) + return layoutNode +} + +internal fun FakePointerInputModifierNode.toModifier(): Modifier { + return object : ModifierNodeElement() { + override fun create(): FakePointerInputModifierNode = this@toModifier + + override fun update(node: FakePointerInputModifierNode) {} + + override fun hashCode(): Int { + return touchBoundsExpansion.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is FakePointerInputModifierNode) return false + return touchBoundsExpansion == other.touchBoundsExpansion + } + } +} + +internal class FakePointerInputModifierNode( + override var touchBoundsExpansion: TouchBoundsExpansion = TouchBoundsExpansion.None, + val interceptOutOfBoundsChildEvents: Boolean = false, +) : Modifier.Node(), PointerInputModifierNode { + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) {} + + override fun onCancelPointerInput() {} + + override fun interceptOutOfBoundsChildEvents(): Boolean { + return interceptOutOfBoundsChildEvents + } +} + +internal fun LayoutNode.hitTest( + pointerPosition: Offset, + pointerType: PointerType = PointerType.Stylus +): List { + val hitTestResult = HitTestResult() + hitTest(pointerPosition, hitTestResult, pointerType) + return hitTestResult +} + +private fun LayoutNode.onNodePlaced() = measurePassDelegate.onNodePlaced() diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt index 09e3b364b852f..e7d573ddce734 100644 --- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt @@ -17,12 +17,14 @@ package androidx.compose.ui.node +import androidx.collection.IntObjectMap +import androidx.collection.intObjectMapOf import androidx.compose.testutils.TestViewConfiguration import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.draw.DrawModifier import androidx.compose.ui.draw.drawBehind @@ -46,6 +48,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerIconService import androidx.compose.ui.input.pointer.PointerInputFilter import androidx.compose.ui.input.pointer.PointerInputModifier +import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.LayoutModifier import androidx.compose.ui.layout.LayoutModifierImpl @@ -64,8 +67,10 @@ import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.platform.invertTo +import androidx.compose.ui.semantics.EmptySemanticsModifier import androidx.compose.ui.semantics.SemanticsConfiguration import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.text.font.Font @@ -1076,7 +1081,7 @@ class LayoutNodeTest { .apply { attach(MockOwner()) } val hit = mutableListOf() - layoutNode.hitTest(Offset(-3f, 3f), hit, true) + layoutNode.hitTest(Offset(-3f, 3f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter)) } @@ -1096,7 +1101,7 @@ class LayoutNodeTest { .apply { attach(MockOwner()) } val hit = mutableListOf() - layoutNode.hitTest(Offset(0f, 3f), hit, true) + layoutNode.hitTest(Offset(0f, 3f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter)) } @@ -1116,7 +1121,7 @@ class LayoutNodeTest { .apply { attach(MockOwner()) } val hit = mutableListOf() - layoutNode.hitTest(Offset(3f, 0f), hit, true) + layoutNode.hitTest(Offset(3f, 0f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter)) } @@ -1138,7 +1143,7 @@ class LayoutNodeTest { layoutNode.onNodePlaced() val hit = mutableListOf() - outerNode.hitTest(Offset(-3f, 3f), hit, true) + outerNode.hitTest(Offset(-3f, 3f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter)) } @@ -1212,42 +1217,42 @@ class LayoutNodeTest { val hit = mutableListOf() // Hit closer to layoutNode1 - outerNode.hitTest(Offset(5.1f, 5.5f), hit, true) + outerNode.hitTest(Offset(5.1f, 5.5f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter1)) hit.clear() // Hit closer to layoutNode2 - outerNode.hitTest(Offset(5.9f, 5.5f), hit, true) + outerNode.hitTest(Offset(5.9f, 5.5f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter2)) hit.clear() // Hit closer to layoutNode1 - outerNode.hitTest(Offset(5.5f, 5.1f), hit, true) + outerNode.hitTest(Offset(5.5f, 5.1f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter1)) hit.clear() // Hit closer to layoutNode2 - outerNode.hitTest(Offset(5.5f, 5.9f), hit, true) + outerNode.hitTest(Offset(5.5f, 5.9f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter2)) hit.clear() // Hit inside layoutNode1 - outerNode.hitTest(Offset(4.9f, 4.9f), hit, true) + outerNode.hitTest(Offset(4.9f, 4.9f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter1)) hit.clear() // Hit inside layoutNode2 - outerNode.hitTest(Offset(6.1f, 6.1f), hit, true) + outerNode.hitTest(Offset(6.1f, 6.1f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter2)) } @@ -1326,14 +1331,14 @@ class LayoutNodeTest { val hit = mutableListOf() // Hit outside of layoutNode2, but near layoutNode1 - outerNode.hitTest(Offset(10.1f, 10.1f), hit, true) + outerNode.hitTest(Offset(10.1f, 10.1f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter2, pointerInputFilter1)) hit.clear() // Hit closer to layoutNode3 - outerNode.hitTest(Offset(11.9f, 11.9f), hit, true) + outerNode.hitTest(Offset(11.9f, 11.9f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter3)) } @@ -1370,14 +1375,14 @@ class LayoutNodeTest { val hit = mutableListOf() // Hit layoutNode1 - outerNode.hitTest(Offset(3.95f, 3.95f), hit, true) + outerNode.hitTest(Offset(3.95f, 3.95f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter1)) hit.clear() // Hit layoutNode2 - outerNode.hitTest(Offset(4.05f, 4.05f), hit, true) + outerNode.hitTest(Offset(4.05f, 4.05f), hit, PointerType.Touch) assertThat(hit.toFilters()).isEqualTo(listOf(pointerInputFilter2)) } @@ -1448,42 +1453,42 @@ class LayoutNodeTest { // Hit closer to layoutNode1 val hit1 = HitTestResult() - outerNode.hitTestSemantics(Offset(5.1f, 5.5f), hit1, true) + outerNode.hitTestSemantics(Offset(5.1f, 5.5f), hit1, PointerType.Touch) assertThat(hit1).hasSize(1) assertThat(hit1[0]).isEqualTo(semanticsNode1) // Hit closer to layoutNode2 val hit2 = HitTestResult() - outerNode.hitTestSemantics(Offset(5.9f, 5.5f), hit2, true) + outerNode.hitTestSemantics(Offset(5.9f, 5.5f), hit2, PointerType.Touch) assertThat(hit2).hasSize(1) assertThat(hit2[0]).isEqualTo(semanticsNode2) // Hit closer to layoutNode1 val hit3 = HitTestResult() - outerNode.hitTestSemantics(Offset(5.5f, 5.1f), hit3, true) + outerNode.hitTestSemantics(Offset(5.5f, 5.1f), hit3, PointerType.Touch) assertThat(hit3).hasSize(1) assertThat(hit3[0]).isEqualTo(semanticsNode1) // Hit closer to layoutNode2 val hit4 = HitTestResult() - outerNode.hitTestSemantics(Offset(5.5f, 5.9f), hit4, true) + outerNode.hitTestSemantics(Offset(5.5f, 5.9f), hit4, PointerType.Touch) assertThat(hit4).hasSize(1) assertThat(hit4[0]).isEqualTo(semanticsNode2) // Hit inside layoutNode1 val hit5 = HitTestResult() - outerNode.hitTestSemantics(Offset(4.9f, 4.9f), hit5, true) + outerNode.hitTestSemantics(Offset(4.9f, 4.9f), hit5, PointerType.Touch) assertThat(hit5).hasSize(1) assertThat(hit5[0]).isEqualTo(semanticsNode1) // Hit inside layoutNode2 val hit6 = HitTestResult() - outerNode.hitTestSemantics(Offset(6.1f, 6.1f), hit6, true) + outerNode.hitTestSemantics(Offset(6.1f, 6.1f), hit6, PointerType.Touch) assertThat(hit6).hasSize(1) assertThat(hit6[0]).isEqualTo(semanticsNode2) @@ -1510,14 +1515,14 @@ class LayoutNodeTest { // Hit layoutNode1 val hit1 = HitTestResult() - outerNode.hitTestSemantics(Offset(3.95f, 3.95f), hit1, true) + outerNode.hitTestSemantics(Offset(3.95f, 3.95f), hit1, PointerType.Touch) assertThat(hit1).hasSize(1) assertThat(hit1[0].toModifier()).isEqualTo(semanticsModifier1) // Hit layoutNode2 val hit2 = HitTestResult() - outerNode.hitTestSemantics(Offset(4.05f, 4.05f), hit2, true) + outerNode.hitTestSemantics(Offset(4.05f, 4.05f), hit2, PointerType.Touch) assertThat(hit2).hasSize(1) assertThat(hit2[0].toModifier()).isEqualTo(semanticsModifier2) @@ -2312,6 +2317,8 @@ private class EmptyLayoutModifier : LayoutModifier { internal class MockOwner( private val position: IntOffset = IntOffset.Zero, override val root: LayoutNode = LayoutNode(), + override val semanticsOwner: SemanticsOwner = + SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf()), override val coroutineContext: CoroutineContext = Executors.newFixedThreadPool(3).asCoroutineDispatcher() ) : Owner { @@ -2347,7 +2354,7 @@ internal class MockOwner( override val autofill: Autofill? get() = TODO("Not yet implemented") - override val semanticAutofill: SemanticAutofill + override val autofillManager: AutofillManager get() = TODO("Not yet implemented") override val density: Density @@ -2544,16 +2551,19 @@ internal class MockOwner( override val viewConfiguration: ViewConfiguration get() = TODO("Not yet implemented") + override val layoutNodes: IntObjectMap + get() = TODO("Not yet implemented") + override val sharedDrawScope = LayoutNodeDrawScope() } private fun LayoutNode.hitTest( pointerPosition: Offset, hitPointerInputFilters: MutableList, - isTouchEvent: Boolean = false + pointerType: PointerType = PointerType.Unknown ) { val hitTestResult = HitTestResult() - hitTest(pointerPosition, hitTestResult, isTouchEvent) + hitTest(pointerPosition, hitTestResult, pointerType) hitPointerInputFilters.addAll(hitTestResult) } @@ -2563,12 +2573,14 @@ internal fun LayoutNode( x2: Int, y2: Int, modifier: Modifier = Modifier, - minimumTouchTargetSize: DpSize = DpSize.Zero + minimumTouchTargetSize: DpSize = DpSize.Zero, + layoutDirection: LayoutDirection = LayoutDirection.Ltr ) = LayoutNode().apply { this.viewConfiguration = TestViewConfiguration(minimumTouchTargetSize = minimumTouchTargetSize) this.modifier = modifier + this.layoutDirection = layoutDirection measurePolicy = object : LayoutNode.NoIntrinsicsMeasurePolicy("not supported") { override fun MeasureScope.measure( diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt index c4fce35124786..7ff7ee09cbcbb 100644 --- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt @@ -18,6 +18,8 @@ package androidx.compose.ui.node +import androidx.collection.IntObjectMap +import androidx.collection.intObjectMapOf import androidx.compose.runtime.collection.mutableVectorOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,8 +28,8 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusOwner import androidx.compose.ui.geometry.Offset @@ -49,6 +51,8 @@ import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.semantics.EmptySemanticsModifier +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -332,7 +336,9 @@ class ModifierLocalConsumerEntityTest { override fun onDetach(node: LayoutNode) {} - override val root: LayoutNode + override val root: LayoutNode = LayoutNode() + + override val layoutNodes: IntObjectMap get() = TODO("Not yet implemented") override val sharedDrawScope: LayoutNodeDrawScope @@ -374,6 +380,9 @@ class ModifierLocalConsumerEntityTest { override val focusOwner: FocusOwner get() = TODO("Not yet implemented") + override val semanticsOwner: SemanticsOwner = + SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf()) + override val windowInfo: WindowInfo get() = TODO("Not yet implemented") @@ -405,7 +414,7 @@ class ModifierLocalConsumerEntityTest { override val autofill: Autofill get() = TODO("Not yet implemented") - override val semanticAutofill: SemanticAutofill + override val autofillManager: AutofillManager get() = TODO("Not yet implemented") override fun createLayer( @@ -440,7 +449,7 @@ class ModifierLocalConsumerEntityTest { override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) = TODO("Not yet implemented") - override fun onSemanticsChange() = TODO("Not yet implemented") + override fun onSemanticsChange() {} override fun onLayoutChange(layoutNode: LayoutNode) = TODO("Not yet implemented") diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/TouchBoundsExpansionTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/TouchBoundsExpansionTest.kt new file mode 100644 index 0000000000000..dd7c3761b45ea --- /dev/null +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/TouchBoundsExpansionTest.kt @@ -0,0 +1,98 @@ +/* + * 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. + */ + +package androidx.compose.ui.node + +import com.google.common.truth.Truth.assertThat +import kotlin.test.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class TouchBoundsExpansionTest { + @Test + fun create() { + val touchBoundsExpansion = TouchBoundsExpansion(1, 2, 3, 4) + + assertThat(touchBoundsExpansion.start).isEqualTo(1) + assertThat(touchBoundsExpansion.top).isEqualTo(2) + assertThat(touchBoundsExpansion.end).isEqualTo(3) + assertThat(touchBoundsExpansion.bottom).isEqualTo(4) + assertThat(touchBoundsExpansion.isLayoutDirectionAware).isTrue() + } + + @Test + fun create_absolute() { + val touchBoundsExpansion = TouchBoundsExpansion.Absolute(1, 2, 3, 4) + + assertThat(touchBoundsExpansion.start).isEqualTo(1) + assertThat(touchBoundsExpansion.top).isEqualTo(2) + assertThat(touchBoundsExpansion.end).isEqualTo(3) + assertThat(touchBoundsExpansion.bottom).isEqualTo(4) + assertThat(touchBoundsExpansion.isLayoutDirectionAware).isFalse() + } + + @Test + fun throwIllegalArgumentException_whenValueIsNegative() { + try { + TouchBoundsExpansion(start = -1) + fail("Expect IllegalArgumentException when start is negative") + } catch (_: IllegalArgumentException) {} + + try { + TouchBoundsExpansion(top = -1) + fail("Expect IllegalArgumentException when top is negative") + } catch (_: IllegalArgumentException) {} + + try { + TouchBoundsExpansion(end = -1) + fail("Expect IllegalArgumentException when end is negative") + } catch (_: IllegalArgumentException) {} + + try { + TouchBoundsExpansion(bottom = -1) + fail("Expect IllegalArgumentException when bottom is negative") + } catch (_: IllegalArgumentException) {} + } + + @Test + fun throwIllegalArgumentException_whenValueExceedsMax() { + assertThat(TouchBoundsExpansion(start = 32767).start).isEqualTo(32767) + try { + TouchBoundsExpansion(start = 32768) + fail("Expect IllegalArgumentException when start is too large") + } catch (_: IllegalArgumentException) {} + + assertThat(TouchBoundsExpansion(top = 32767).top).isEqualTo(32767) + try { + TouchBoundsExpansion(top = 32768) + fail("Expect IllegalArgumentException when top is too large") + } catch (_: IllegalArgumentException) {} + + assertThat(TouchBoundsExpansion(end = 32767).end).isEqualTo(32767) + try { + TouchBoundsExpansion(end = 32768) + fail("Expect IllegalArgumentException when end is too large") + } catch (_: IllegalArgumentException) {} + + assertThat(TouchBoundsExpansion(bottom = 32767).bottom).isEqualTo(32767) + try { + TouchBoundsExpansion(bottom = 32768) + fail("Expect IllegalArgumentException when bottom is too large") + } catch (_: IllegalArgumentException) {} + } +} diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/RectListTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/RectListTest.kt index 21050a3e2f10c..c1ec05cb5c632 100644 --- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/RectListTest.kt +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/RectListTest.kt @@ -86,6 +86,17 @@ class RectListTest { } } + @Test + fun insertUpdateClearUpdatedRemove() { + val list = RectList() + list.insert(1, 1, 1, 2, 2) + list.update(1, 2, 2, 3, 3) + list.remove(1) + list.clearUpdated() + list.defragment() + assertEquals(0, list.size) + } + @Test fun testFindIntersectingPoint() { val testData = exampleLayoutRects @@ -361,12 +372,14 @@ class RectListTest { itemId = 1, parentId = 2, lastChildOffset = 3, + updated = true, focusable = false, gesturable = true ) assertEquals(1, unpackMetaValue(meta)) assertEquals(2, unpackMetaParentId(meta)) assertEquals(3, unpackMetaLastChildOffset(meta)) + assertEquals(1, unpackMetaUpdated(meta)) assertEquals(0, unpackMetaFocusable(meta)) assertEquals(1, unpackMetaGesturable(meta)) } @@ -378,12 +391,14 @@ class RectListTest { itemId = 10, parentId = -1, lastChildOffset = 0, + updated = true, focusable = true, gesturable = false, ) assertEquals(10, unpackMetaValue(meta)) // TODO: this actually returns 268,435,455. Not sure if we need to change this or not. // assertEquals(-1, unpackMetaParentScrollableValue(meta)) + assertEquals(1, unpackMetaUpdated(meta)) assertEquals(1, unpackMetaFocusable(meta)) assertEquals(0, unpackMetaGesturable(meta)) } diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt new file mode 100644 index 0000000000000..a98b7e956fcae --- /dev/null +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt @@ -0,0 +1,245 @@ +/* + * 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. + */ + +package androidx.compose.ui.spatial + +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.LayoutNode +import kotlinx.coroutines.DisposableHandle +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ThrottledCallbacksTest { + + @Test + fun testImmediateFire() = test { + var called = 0 + register(1, throttleMs = 0, debounceMs = 0) { called++ } + fire(1) + assertEquals(1, called) + } + + @Test + fun testImmediateFireMultipleCallbacks() = test { + var a = 0 + register(1, throttleMs = 0, debounceMs = 0) { a++ } + var b = 0 + register(1, throttleMs = 0, debounceMs = 0) { b++ } + fire(1) + assertEquals(1, a) + assertEquals(1, b) + } + + @Test + fun testImmediateFireMultipleCallbacksRemoval() = test { + var a = 0 + val ha = register(1, throttleMs = 0, debounceMs = 0) { a++ } + var b = 0 + val hb = register(1, throttleMs = 0, debounceMs = 0) { b++ } + var c = 0 + val hc = register(1, throttleMs = 0, debounceMs = 0) { c++ } + fire(1) + assertEquals(1, a) + assertEquals(1, b) + assertEquals(1, c) + + hb.dispose() + fire(1) + + assertEquals(2, a) + assertEquals(1, b) + assertEquals(2, c) + + ha.dispose() + fire(1) + + assertEquals(2, a) + assertEquals(1, b) + assertEquals(3, c) + + hc.dispose() + fire(1) + + assertEquals(2, a) + assertEquals(1, b) + assertEquals(3, c) + } + + @Test + fun testImmediateFireOnlyTargetsId() = test { + var a = 0 + register(1, throttleMs = 0, debounceMs = 0) { a++ } + var b = 0 + register(2, throttleMs = 0, debounceMs = 0) { b++ } + fire(1) + assertEquals(1, a) + assertEquals(0, b) + advanceBy(1) + fire(2) + assertEquals(1, a) + assertEquals(1, b) + advanceBy(1) + fire(2) + assertEquals(1, a) + assertEquals(2, b) + } + + @Test + fun testDebounceSingleFire() = test { + var called = 0 + register(1, throttleMs = 0, debounceMs = 10) { called++ } + fire(1) + assertEquals(0, called) + advanceBy(8) + assertEquals(0, called) + advanceBy(8) + assertEquals(1, called) + } + + @Test + fun testDebounceManyFire() = test { + var called = 0 + register(1, throttleMs = 0, debounceMs = 10) { called++ } + fire(1) + assertEquals(0, called) + advanceBy(8) + assertEquals(0, called) + fire(1) + assertEquals(0, called) + advanceBy(8) + assertEquals(0, called) + fire(1) + assertEquals(0, called) + advanceBy(8) + assertEquals(0, called) + advanceBy(8) + assertEquals(1, called) + } + + @Test + fun testThrottleSingleFire() = test { + var called = 0 + register(1, throttleMs = 10, debounceMs = 0) { called++ } + fire(1) + assertEquals(1, called) + advanceBy(8) + assertEquals(1, called) + fire(1) + assertEquals(1, called) + advanceBy(8) + assertEquals(1, called) + fire(1) + assertEquals(2, called) + advanceBy(8) + assertEquals(2, called) + fire(1) + assertEquals(2, called) + } + + @Test + fun testThrottleRapidFireCallsOncePerDuration() = test { + var called = 0 + register(1, throttleMs = 10, debounceMs = 0) { called++ } + repeat(10) { + fire(1) + advanceBy(1) + assertEquals(1, called) + } + // it is at this point that we cross over the throttle deadline, so we get the second fire + fire(1) + advanceBy(1) + assertEquals(2, called) + + // go ahead and fire once more. it won't invoke right now... + fire(1) + advanceBy(1) + assertEquals(2, called) + + // but if we wait more than 10ms it will fire the state that it settled on + advanceBy(12) + assertEquals(3, called) + } + + @Test + fun testThrottleAndDebounceSlowFiring() = test { + var called = 0 + register(1, throttleMs = 20, debounceMs = 10) { called++ } + fire(1) + assertEquals(1, called) // called right away + advanceBy(30) // more than throttle and debounce time passes + assertEquals(1, called) + fire(1) + assertEquals(2, called) // called right away + } + + @Test + fun testThrottleAndDebounceRapidFiring() = test { + var called = 0 + register(1, throttleMs = 20, debounceMs = 10) { called++ } + fire(1) + assertEquals(1, called) // called right away + advanceBy(16) // above debounce but below throttle + assertEquals(1, called) // not called again + fire(1) // this should get ignored since it is below throttle limit + assertEquals(1, called) + advanceBy(12) // advance above debounce limit + // after debounce deadline passed, lambda gets called because the last position was lost + assertEquals(2, called) + advanceBy(30) // more than throttle and debounce time passes + assertEquals(2, called) + fire(1) + assertEquals(3, called) // called right away + } + + var currentTime: Long = 1 + + private fun ThrottledCallbacks.advanceBy(ms: Long) { + currentTime += ms + triggerDebounced(currentTime) + } + + private fun ThrottledCallbacks.fire(id: Int) { + fire(id, 0, 0, currentTime) + } + + private fun ThrottledCallbacks.register( + id: Int, + throttleMs: Long, + debounceMs: Long, + callback: (RectInfo) -> Unit + ): DisposableHandle { + return register(id, throttleMs, debounceMs, fakeNode(), callback) + } + + private inline fun test(block: ThrottledCallbacks.() -> Unit) { + val cbs = ThrottledCallbacks() + currentTime = 1 + block(cbs) + } + + private fun fakeNode(): DelegatableNode { + val node = object : Modifier.Node() {} + + val layoutNode = LayoutNode(0, 0, 0, 0) + node.updateCoordinator(layoutNode.outerCoordinator) + layoutNode.measurePassDelegate.isPlaced = true + return node + } +} diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/text/TextActionModeCallbackTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/text/TextActionModeCallbackTest.kt index 23ce37c489943..0a6ecb3732581 100644 --- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/text/TextActionModeCallbackTest.kt +++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/text/TextActionModeCallbackTest.kt @@ -33,9 +33,57 @@ import androidx.compose.ui.platform.actionmodecallback.TextActionModeCallback import androidx.test.filters.SdkSuppress import com.google.common.truth.Truth.assertThat import org.junit.Test +import org.mockito.kotlin.mock @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) class TextActionModeCallbackTest { + @Test + @SdkSuppress(maxSdkVersion = 25) + fun onCreateActionMode_beforeApi26() { + val callback = + TextActionModeCallback( + onCopyRequested = {}, + onPasteRequested = {}, + onCutRequested = {}, + onSelectAllRequested = {}, + onAutofillRequested = {} + ) + val menu = ItemTrackingFakeMenu() + callback.onCreateActionMode(mock(), menu) + + // Before API 26, the last menu item should be "Select all." + val lastItem = menu.menuItems.last() + assertThat(lastItem.itemId).isEqualTo(MenuItemOption.SelectAll.id) + assertThat(lastItem.order).isEqualTo(MenuItemOption.SelectAll.id) + assertThat(lastItem.title).isEqualTo(MenuItemOption.SelectAll.titleResource.toString()) + } + + @Test + @SdkSuppress(minSdkVersion = 26) + @RequiresApi(26) + fun onCreateActionMode_afterApi26() { + // TODO(mnuzen): investigate why `NullDevice` has API level 0 + if (Build.VERSION.SDK_INT == 0) { + return + } + val callback = + TextActionModeCallback( + onCopyRequested = {}, + onPasteRequested = {}, + onCutRequested = {}, + onSelectAllRequested = {}, + onAutofillRequested = {} + ) + val menu = ItemTrackingFakeMenu() + callback.onCreateActionMode(mock(), menu) + + // For API level 26 and on, the last menu item should be "Autofill." + val lastItem = menu.menuItems.last() + assertThat(lastItem.itemId).isEqualTo(MenuItemOption.Autofill.id) + assertThat(lastItem.order).isEqualTo(MenuItemOption.Autofill.id) + assertThat(lastItem.title).isEqualTo(MenuItemOption.Autofill.titleResource.toString()) + } + @Test fun addMenuItem_correctValues() { val callback = TextActionModeCallback(onCopyRequested = { /* copy */ }) diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt index 536ef76cb616c..728a1bb31f9db 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt @@ -63,4 +63,21 @@ object ComposeUiFlags { * layout phases, it is possible that the addition of this tracking is the culprit. */ @Suppress("MutableBareField") @JvmField var isRectTrackingEnabled: Boolean = true + + /** + * Selecting flag to enable the change new onPostFling nested scroll behavior for ongoing + * flings. If a nested scroll node is removed from the tree before sending the onPostFling + * callback, we will hold on to the next node in the tree so we have a handle to send the + * information after the fling finish/is cancelled. + */ + @Suppress("MutableBareField") + @JvmField + var NewNestedScrollFlingDispatchingEnabled: Boolean = true + + /** + * With this flag on, the new semantic version of Autofill will be enabled. Prior to the + * semantics refactoring, this will introduce significant overhead, but can be used to test out + * the new Autofill APIs and features introduced. + */ + @Suppress("MutableBareField") @JvmField var isSemanticAutofillEnabled: Boolean = false } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Expect.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Expect.kt index 537a56dfe2de7..8732a32a1c591 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Expect.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Expect.kt @@ -38,6 +38,12 @@ internal expect fun classKeyForObject(a: Any): Any // https://android-review.googlesource.com/c/platform/frameworks/support/+/2441379 internal expect fun InspectorInfo.tryPopulateReflectively(element: ModifierNodeElement<*>) +internal expect fun currentTimeMillis(): Long + +internal expect fun postDelayed(delayMillis: Long, block: () -> Unit): Any + +internal expect fun removePost(token: Any?) + /** * Represents a platform-optimized cancellation exception. * This allows us to configure exceptions separately on JVM and other platforms. diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/Autofill.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/Autofill.kt index 343bc20c4bd0a..9c45245821984 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/Autofill.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/Autofill.kt @@ -17,7 +17,7 @@ package androidx.compose.ui.autofill import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.platform.createSynchronizedObject +import androidx.compose.ui.platform.makeSynchronizedObject import androidx.compose.ui.platform.synchronized /** @@ -73,7 +73,8 @@ class AutofillNode( /*@GuardedBy("this")*/ private var previousId = 0 - private val lock = createSynchronizedObject() + private val lock = makeSynchronizedObject(this) + private fun generateId() = synchronized(lock) { ++previousId } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt new file mode 100644 index 0000000000000..79f4666ca9b78 --- /dev/null +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt @@ -0,0 +1,54 @@ +/* + * 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. + */ + +package androidx.compose.ui.autofill + +/** + * Autofill API. + * + * This interface is available to all composables via a CompositionLocal. The composable can then + * notify the Autofill framework that user values have been committed as required. + */ +interface AutofillManager { + + /** + * Indicate the autofill context should be committed. + * + * Call this function to notify the Autofill framework that the current context should be + * committed. After calling this function, the framework considers the form submitted, and the + * credentials entered will be processed. + */ + fun commit() + + /** + * Indicate the autofill context should be canceled. + * + * Call this function to notify the Autofill framework that the current context should be + * canceled. After calling this function, the framework will stop the current autofill session + * without processing any information entered in the autofillable field. + */ + fun cancel() + + /** + * Request autofill for previously focused element. + * + * This may have no effect, and it is not required that any autofill service will be notified. + * + * Any component that can be autofilled may call this when it is active to request an autofill + * services response. + */ + fun requestAutofillForActiveElement() +} diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt index 1ba2c9fa8e77e..504d86a66ba30 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt @@ -165,7 +165,7 @@ enum class AutofillType { /** Indicates that the associated component can be autofilled with a birth day(of the month). */ BirthDateDay, - /** Indicates that the associated component can be autofilled with a birth day(of the month). */ + /** Indicates that the associated component can be autofilled with a birth month. */ BirthDateMonth, /** Indicates that the associated component can be autofilled with a birth year. */ diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentDataType.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentDataType.kt index 78fddbad83586..860e9efa57002 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentDataType.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentDataType.kt @@ -16,20 +16,32 @@ package androidx.compose.ui.autofill -// TODO(b/333102566): When Autofill goes live for Compose, -// these classes will need to be made public. - -// Using `typealias` with the internal qualifier triggers a Kotlin visibility issue. -// For now, the types will be kept internal and just as Ints. When Autofill goes live, -// the `NativeContentDataType` and `ContentDataType` classes will be used. -// expect class NativeContentDataType +/** + * Content data type information. + * + * Autofill services use the [ContentDataType] to determine what kind of field is associated with + * the component. + */ +expect class NativeContentDataType -internal expect value class ContentDataType(val dataType: Int) { - internal companion object { +expect value class ContentDataType(val dataType: NativeContentDataType) { + companion object { + /** Indicates that the associated component is a text field. */ val Text: ContentDataType + + /** Indicates that the associated component is a list. */ val List: ContentDataType + + /** Indicates that the associated component is a date. */ val Date: ContentDataType + + /** Indicates that the associated component is a toggle. */ val Toggle: ContentDataType + + /** + * Indicates that the associated component does not have a data type, and therefore is not + * autofillable. + */ val None: ContentDataType } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentType.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentType.kt index cdcf667be8829..dcfa0dc60c9bb 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentType.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/ContentType.kt @@ -23,11 +23,8 @@ package androidx.compose.ui.autofill * associated with this type. If the [ContentType] is not specified, the autofill services have to * use heuristics to determine the right value to use while autofilling the corresponding field. */ -// TODO(b/333102566): When Autofill goes live for Compose, -// these classes will need to be made public. -internal expect class ContentType private constructor(contentHint: String) { - - internal companion object { +expect class ContentType private constructor(contentHint: String) { + companion object { /** Indicates that the associated component can be autofilled with an email address. */ val EmailAddress: ContentType @@ -189,9 +186,7 @@ internal expect class ContentType private constructor(contentHint: String) { */ val BirthDateDay: ContentType - /** - * Indicates that the associated component can be autofilled with a birth day(of the month). - */ + /** Indicates that the associated component can be autofilled with a birth month. */ val BirthDateMonth: ContentType /** Indicates that the associated component can be autofilled with a birth year. */ @@ -202,7 +197,5 @@ internal expect class ContentType private constructor(contentHint: String) { * (OTP). */ val SmsOtpCode: ContentType - - internal fun from(value: String): ContentType } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwner.kt index 4f4d655a60266..a22a570c3dc03 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwner.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwner.kt @@ -130,7 +130,10 @@ internal interface FocusOwner : FocusManager { fun dispatchInterceptedSoftKeyboardEvent(keyEvent: KeyEvent): Boolean /** Dispatches a rotary scroll event through the compose hierarchy. */ - fun dispatchRotaryEvent(event: RotaryScrollEvent): Boolean + fun dispatchRotaryEvent( + event: RotaryScrollEvent, + onFocusedItem: () -> Boolean = { false } + ): Boolean /** Schedule a FocusTarget node to be invalidated after onApplyChanges. */ fun scheduleInvalidation(node: FocusTargetNode) diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt index 368f783aa4610..5418be976c4bf 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.focus.FocusDirection.Companion.Next import androidx.compose.ui.focus.FocusDirection.Companion.Previous import androidx.compose.ui.focus.FocusRequester.Companion.Cancel import androidx.compose.ui.focus.FocusRequester.Companion.Default +import androidx.compose.ui.focus.FocusRequester.Companion.Redirect import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType.Companion.KeyDown @@ -239,10 +240,11 @@ internal class FocusOwnerImpl( onFound: (FocusTargetNode) -> Boolean ): Boolean? { val source = - rootFocusNode.findActiveFocusNode()?.also { + findFocusTargetNode()?.also { // Check if a custom focus traversal order is specified. when (val customDest = it.customFocusSearch(focusDirection, onLayoutDirection())) { Cancel -> return null + Redirect -> return findFocusTargetNode()?.let(onFound) Default -> { /* Do Nothing */ } @@ -269,7 +271,7 @@ internal class FocusOwnerImpl( } if (!validateKeyEvent(keyEvent)) return false - val activeFocusTarget = rootFocusNode.findActiveFocusNode() + val activeFocusTarget = findFocusTargetNode() val focusedKeyInputNode = activeFocusTarget?.lastLocalKeyInputNode() ?: activeFocusTarget?.nearestAncestorIncludingSelf(Nodes.KeyInput)?.node @@ -310,18 +312,21 @@ internal class FocusOwnerImpl( } /** Dispatches a rotary scroll event through the compose hierarchy. */ - override fun dispatchRotaryEvent(event: RotaryScrollEvent): Boolean { + override fun dispatchRotaryEvent( + event: RotaryScrollEvent, + onFocusedItem: () -> Boolean + ): Boolean { check(!focusInvalidationManager.hasPendingInvalidation()) { "Dispatching rotary event while focus system is invalidated." } val focusedRotaryInputNode = - rootFocusNode.findActiveFocusNode()?.nearestAncestorIncludingSelf(Nodes.RotaryInput) + findFocusTargetNode()?.nearestAncestorIncludingSelf(Nodes.RotaryInput) focusedRotaryInputNode?.traverseAncestorsIncludingSelf( type = Nodes.RotaryInput, onPreVisit = { if (it.onPreRotaryScrollEvent(event)) return true }, - onVisit = { /* TODO(b/320510084): dispatch rotary events to embedded views. */ }, + onVisit = { if (onFocusedItem()) return true }, onPostVisit = { if (it.onRotaryScrollEvent(event)) return true } ) @@ -377,7 +382,11 @@ internal class FocusOwnerImpl( /** Searches for the currently focused item, and returns its coordinates as a rect. */ override fun getFocusRect(): Rect? { - return rootFocusNode.findActiveFocusNode()?.focusRect() + return findFocusTargetNode()?.focusRect() + } + + private fun findFocusTargetNode(): FocusTargetNode? { + return rootFocusNode.findActiveFocusNode() } override val rootState: FocusState diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusProperties.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusProperties.kt index 452887ef8991a..381af3db41175 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusProperties.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusProperties.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.focus +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.InspectorInfo @@ -123,14 +124,37 @@ interface FocusProperties { * @sample androidx.compose.ui.samples.CustomFocusEnterSample */ @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") + @ExperimentalComposeUiApi + @get:ExperimentalComposeUiApi + @set:ExperimentalComposeUiApi + @set:Deprecated("Use onEnter instead", ReplaceWith("onEnter")) var enter: (FocusDirection) -> FocusRequester get() = { FocusRequester.Default } + set(value) { + onEnter = value.toUsingEnterExitScope() + } + + /** + * A custom item to be used when the user requests focus to move focus in + * ([FocusDirection.Enter]). An automatic [Enter][FocusDirection.Enter]" can be triggered when + * we move focus to a focus group that is not itself focusable. In this case, users can use the + * the focus direction that triggered the move in to determine the next item to be focused on. + * + * When you set the [onEnter] property, provide a lambda with the [FocusEnterExitScope] scope, + * having the [FocusEnterExitScope.requestedFocusDirection] that triggered the enter as an + * input. If redirection is required, use [FocusRequester.requestFocus] and if the focus change + * should be canceled, use [FocusEnterExitScope.cancelFocus]. + * + * @sample androidx.compose.ui.samples.CustomFocusEnterSample + */ + var onEnter: FocusEnterExitScope.() -> Unit + get() = {} set(_) {} /** * A custom item to be used when the user requests focus to move out ([FocusDirection.Exit]). An * automatic [Exit][FocusDirection.Exit] can be triggered when we move focus outside the edge of - * a parent. In this case, users can use the the focus direction that triggered the move out to + * a parent. In this case, users can use the focus direction that triggered the move out to * determine the next focus destination. * * When you set the [exit] property, provide a lambda that takes the FocusDirection that @@ -142,11 +166,75 @@ interface FocusProperties { * @sample androidx.compose.ui.samples.CustomFocusExitSample */ @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") + @ExperimentalComposeUiApi + @get:ExperimentalComposeUiApi + @set:ExperimentalComposeUiApi + @set:Deprecated("Use onExit instead", ReplaceWith("onExit")) var exit: (FocusDirection) -> FocusRequester get() = { FocusRequester.Default } + set(value) { + onExit = value.toUsingEnterExitScope() + } + + /** + * A custom item to be used when the user requests focus to move out ([FocusDirection.Exit]). An + * automatic [Exit][FocusDirection.Exit] can be triggered when we move focus outside the edge of + * a parent. In this case, users can use the focus direction that triggered the move out to + * determine the next focus destination. + * + * When you set the [onExit] property, provide a lambda with the [FocusEnterExitScope] scope, + * having the [FocusEnterExitScope.requestedFocusDirection] that triggered the exit as an input. + * If redirection is required, use [FocusRequester.requestFocus] and if the focus change should + * be canceled, use [FocusEnterExitScope.cancelFocus]. + * + * @sample androidx.compose.ui.samples.CustomFocusExitSample + */ + var onExit: FocusEnterExitScope.() -> Unit + get() = {} set(_) {} } +/** + * A utility to use when upgrading from [FocusProperties.enter] and [FocusProperties.exit] to + * [FocusProperties.onEnter] and [FocusProperties.onExit]. + */ +private fun ((FocusDirection) -> FocusRequester).toUsingEnterExitScope(): + FocusEnterExitScope.() -> Unit = { + val focusRequester = invoke(requestedFocusDirection) + if (focusRequester === FocusRequester.Cancel) { + cancelFocus() + } else if (focusRequester !== FocusRequester.Default) { + focusRequester.requestFocus() + } +} + +/** + * Receiver scope for [FocusProperties.onEnter] and [FocusProperties.onExit]. Developers can change + * focus with [FocusRequester.requestFocus] to change the focus or [cancelFocus] to stop the focus + * from changing. + */ +sealed interface FocusEnterExitScope { + /** + * The direction used to get into (with [FocusProperties.onEnter]) or leave (with + * [FocusProperties.onExit]) focus. + */ + val requestedFocusDirection: FocusDirection + + /** Stop focus from changing. */ + fun cancelFocus() +} + +internal class CancelIndicatingFocusBoundaryScope( + override val requestedFocusDirection: FocusDirection, +) : FocusEnterExitScope { + var isCanceled = false + private set + + override fun cancelFocus() { + isCanceled = true + } +} + internal class FocusPropertiesImpl : FocusProperties { override var canFocus: Boolean = true override var next: FocusRequester = FocusRequester.Default @@ -157,8 +245,8 @@ internal class FocusPropertiesImpl : FocusProperties { override var right: FocusRequester = FocusRequester.Default override var start: FocusRequester = FocusRequester.Default override var end: FocusRequester = FocusRequester.Default - override var enter: (FocusDirection) -> FocusRequester = { FocusRequester.Default } - override var exit: (FocusDirection) -> FocusRequester = { FocusRequester.Default } + override var onEnter: FocusEnterExitScope.() -> Unit = {} + override var onExit: FocusEnterExitScope.() -> Unit = {} } /** diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRequester.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRequester.kt index d077c7b6918e9..9d36f2ab11dbe 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRequester.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRequester.kt @@ -171,6 +171,9 @@ class FocusRequester { */ val Cancel = FocusRequester() + /** Used to indicate that the focus has been redirected during an enter/exit lambda. */ + internal val Redirect = FocusRequester() + /** * Convenient way to create multiple [FocusRequester] instances. * diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRestorer.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRestorer.kt index d9bb686f2882f..915061622af79 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRestorer.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusRestorer.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.focus import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester.Companion.Cancel import androidx.compose.ui.focus.FocusRequester.Companion.Default @@ -75,39 +76,54 @@ internal fun FocusTargetNode.pinFocusedChild(): PinnedHandle? { * group, it stores a reference to the item that was previously focused. Then when focus re-enters * this focus group, it restores focus to the previously focused item. * - * @param onRestoreFailed callback provides a lambda that is invoked if focus restoration fails. - * This lambda can be used to return a custom fallback item by providing a [FocusRequester] - * attached to that item. This can be used to customize the initially focused item. + * @param fallback A [FocusRequester] that is used when focus restoration fails to allow customizing + * the initially focused item. The default value of [FocusRequester.Default] chooses the default + * focusable item. * @sample androidx.compose.ui.samples.FocusRestorerSample * @sample androidx.compose.ui.samples.FocusRestorerCustomFallbackSample */ -fun Modifier.focusRestorer(onRestoreFailed: (() -> FocusRequester)? = null): Modifier = - this then FocusRestorerElement(onRestoreFailed) +fun Modifier.focusRestorer(fallback: FocusRequester = Default): Modifier = + this then FocusRestorerElement(fallback) -internal class FocusRestorerNode(var onRestoreFailed: (() -> FocusRequester)?) : +/** + * Deprecated focusRestorer API. Use the version accepting [FocusRequester] instead of the lambda. + * This method will be removed soon after submitting. + */ +@ExperimentalComposeUiApi +@Deprecated( + "Use focusRestorer(FocusRequester) instead", + ReplaceWith("this.focusRestorer(onRestoreFailed())"), + DeprecationLevel.WARNING +) +fun Modifier.focusRestorer(onRestoreFailed: (() -> FocusRequester)?): Modifier = + focusRestorer(fallback = onRestoreFailed?.invoke() ?: Default) + +internal class FocusRestorerNode(var fallback: FocusRequester) : CompositionLocalConsumerModifierNode, FocusPropertiesModifierNode, FocusRequesterModifierNode, Modifier.Node() { private var pinnedHandle: PinnedHandle? = null - private val onExit: (FocusDirection) -> FocusRequester = { + private val onExit: FocusEnterExitScope.() -> Unit = { saveFocusedChild() pinnedHandle?.release() pinnedHandle = pinFocusedChild() - Default } - private val onEnter: (FocusDirection) -> FocusRequester = { - val result = if (restoreFocusedChild()) Cancel else onRestoreFailed?.invoke() + private val onEnter: FocusEnterExitScope.() -> Unit = { pinnedHandle?.release() pinnedHandle = null - result ?: Default + if (restoreFocusedChild() || fallback == Cancel) { + cancelFocus() + } else if (fallback != Default) { + fallback.requestFocus() + } } override fun applyFocusProperties(focusProperties: FocusProperties) { - focusProperties.enter = onEnter - focusProperties.exit = onExit + focusProperties.onEnter = onEnter + focusProperties.onExit = onExit } override fun onDetach() { @@ -117,16 +133,16 @@ internal class FocusRestorerNode(var onRestoreFailed: (() -> FocusRequester)?) : } } -private data class FocusRestorerElement(val onRestoreFailed: (() -> FocusRequester)?) : +private data class FocusRestorerElement(val fallback: FocusRequester) : ModifierNodeElement() { - override fun create() = FocusRestorerNode(onRestoreFailed) + override fun create() = FocusRestorerNode(fallback) override fun update(node: FocusRestorerNode) { - node.onRestoreFailed = onRestoreFailed + node.fallback = fallback } override fun InspectorInfo.inspectableProperties() { name = "focusRestorer" - properties["onRestoreFailed"] = onRestoreFailed + properties["fallback"] = fallback } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt index f649b462010b5..a51d7df29cca5 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt @@ -17,6 +17,8 @@ package androidx.compose.ui.focus import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.node.invalidateSemantics import kotlin.js.JsName /** @@ -50,6 +52,18 @@ sealed interface FocusTargetModifierNode : DelegatableNode { var focusability: Focusability } +// Before aosp/3296711 we would calculate semantics configuration lazily. The focusable +// implementation used to call invalidateSemantics() and then change focus state. However, now that +// we are calculating semantics configuration eagerly, the old implementation of focusable would +// end up calculating semantics configuration before the local copy of focus state is updated. +// To fix this, we added an extra invalidateSemantics() call for the deprecated +// [FocusTargetModifierNode]. +private object InvalidateSemantics { + fun onDispatchEventsCompleted(focusTargetNode: FocusTargetNode) { + (focusTargetNode.node as? SemanticsModifierNode)?.invalidateSemantics() + } +} + /** * Create a [FocusTargetModifierNode] that can be delegated to in order to create a modifier that * makes a component focusable. Use a different instance of [FocusTargetModifierNode] for each @@ -60,7 +74,8 @@ sealed interface FocusTargetModifierNode : DelegatableNode { level = DeprecationLevel.HIDDEN ) @JsName("funFocusTargetModifierNode") -fun FocusTargetModifierNode(): FocusTargetModifierNode = FocusTargetNode() +fun FocusTargetModifierNode(): FocusTargetModifierNode = + FocusTargetNode(onDispatchEventsCompleted = InvalidateSemantics::onDispatchEventsCompleted) /** * Create a [FocusTargetModifierNode] that can be delegated to in order to create a modifier that diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt index 78d77a6056553..3070c77a3cf32 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt @@ -18,7 +18,8 @@ package androidx.compose.ui.focus import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection.Companion.Exit -import androidx.compose.ui.focus.FocusRequester.Companion.Default +import androidx.compose.ui.focus.FocusRequester.Companion.Cancel +import androidx.compose.ui.focus.FocusRequester.Companion.Redirect import androidx.compose.ui.focus.FocusStateImpl.Active import androidx.compose.ui.focus.FocusStateImpl.ActiveParent import androidx.compose.ui.focus.FocusStateImpl.Captured @@ -40,7 +41,8 @@ import androidx.compose.ui.platform.InspectorInfo internal class FocusTargetNode( focusability: Focusability = Focusability.Always, - private val onFocusChange: ((previous: FocusState, current: FocusState) -> Unit)? = null + private val onFocusChange: ((previous: FocusState, current: FocusState) -> Unit)? = null, + private val onDispatchEventsCompleted: ((FocusTargetNode) -> Unit)? = null ) : CompositionLocalConsumerModifierNode, FocusTargetModifierNode, @@ -145,6 +147,24 @@ internal class FocusTargetNode( return properties } + private inline fun fetchCustomEnterOrExit( + focusDirection: FocusDirection, + block: (FocusRequester) -> Unit, + enterOrExit: FocusProperties.(FocusEnterExitScope) -> Unit + ) { + val focusProperties = fetchFocusProperties() + val scope = CancelIndicatingFocusBoundaryScope(focusDirection) + val focusTransactionManager = focusTransactionManager + val generationBefore = focusTransactionManager?.generation ?: 0 + focusProperties.enterOrExit(scope) + val generationAfter = focusTransactionManager?.generation ?: 0 + if (scope.isCanceled) { + block(Cancel) + } else if (generationBefore != generationAfter) { + block(Redirect) + } + } + /** * Fetch custom enter destination associated with this [focusTarget]. * @@ -162,7 +182,7 @@ internal class FocusTargetNode( if (!isProcessingCustomEnter) { isProcessingCustomEnter = true try { - fetchFocusProperties().enter(focusDirection).also { if (it !== Default) block(it) } + fetchCustomEnterOrExit(focusDirection, block) { it.onEnter() } } finally { isProcessingCustomEnter = false } @@ -186,7 +206,7 @@ internal class FocusTargetNode( if (!isProcessingCustomExit) { isProcessingCustomExit = true try { - fetchFocusProperties().exit(focusDirection).also { if (it !== Default) block(it) } + fetchCustomEnterOrExit(focusDirection, block) { it.onExit() } } finally { isProcessingCustomExit = false } @@ -230,7 +250,7 @@ internal class FocusTargetNode( val focusState = focusState // Avoid invoking callback when we initialize the state (from `null` to Inactive) or // if we are detached and go from Inactive to `null` - there isn't a conceptual focus - // state change here + // state change here. if (previousOrInactive != focusState) { onFocusChange?.invoke(previousOrInactive, focusState) } @@ -238,6 +258,8 @@ internal class FocusTargetNode( // TODO(251833873): Consider caching it.getFocusState(). it.onFocusEvent(it.getFocusState()) } + + onDispatchEventsCompleted?.invoke(this) } internal object FocusTargetElement : ModifierNodeElement() { @@ -301,7 +323,7 @@ internal fun FocusTargetNode.requireTransactionManager(): FocusTransactionManage return requireOwner().focusOwner.focusTransactionManager } -private val FocusTargetNode.focusTransactionManager: FocusTransactionManager? +internal val FocusTargetNode.focusTransactionManager: FocusTransactionManager? get() = node.coordinator?.layoutNode?.owner?.focusOwner?.focusTransactionManager internal fun FocusTargetNode.invalidateFocusTarget() { diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactionManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactionManager.kt index 2563f39903c06..40914d4e7c84b 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactionManager.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactionManager.kt @@ -30,6 +30,12 @@ internal class FocusTransactionManager { private val cancellationListener = mutableVectorOf<() -> Unit>() private var ongoingTransaction = false + /** + * An indicator of changes to the transaction. When any state changes, the generation changes. + */ + var generation = 0 + private set + /** * Stars a new transaction, which allows you to change the focus state. Calling this function * causes any ongoing focus transaction to be cancelled. If an [onCancelled] lambda is @@ -78,6 +84,10 @@ internal class FocusTransactionManager { var FocusTargetNode.uncommittedFocusState: FocusStateImpl? get() = states[this] set(value) { + val currentFocusState = states[this] ?: FocusStateImpl.Inactive + if (currentFocusState != value) { + generation++ + } states[this] = checkPreconditionNotNull(value) { "requires a non-null focus state" } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt index 16a40f53f0169..d05f722d4cc87 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.focus.CustomDestinationResult.None import androidx.compose.ui.focus.CustomDestinationResult.RedirectCancelled import androidx.compose.ui.focus.CustomDestinationResult.Redirected import androidx.compose.ui.focus.FocusRequester.Companion.Cancel +import androidx.compose.ui.focus.FocusRequester.Companion.Redirect import androidx.compose.ui.focus.FocusStateImpl.Active import androidx.compose.ui.focus.FocusStateImpl.ActiveParent import androidx.compose.ui.focus.FocusStateImpl.Captured @@ -51,7 +52,7 @@ internal fun FocusTargetNode.requestFocus(focusDirection: FocusDirection): Boole * This function performs the request focus action. * * Note: Do not call this directly, consider using [requestFocus], which will check if any custom - * focus [enter][FocusProperties.enter] and [exit][FocusProperties.exit] + * focus [enter][FocusProperties.onEnter] and [exit][FocusProperties.onExit] * [properties][FocusProperties] have been specified. */ internal fun FocusTargetNode.performRequestFocus(): Boolean { @@ -296,7 +297,7 @@ private fun FocusTargetNode.performCustomEnter( focusDirection: FocusDirection ): CustomDestinationResult { fetchCustomEnter(focusDirection) { - if (it === Cancel) return Cancelled + if (it === Cancel) return Cancelled else if (it === Redirect) return Redirected return if (it.focus()) Redirected else RedirectCancelled } return None @@ -306,7 +307,7 @@ private fun FocusTargetNode.performCustomExit( focusDirection: FocusDirection ): CustomDestinationResult { fetchCustomExit(focusDirection) { - if (it === Cancel) return Cancelled + if (it === Cancel) return Cancelled else if (it === Redirect) return Redirected return if (it.focus()) Redirected else RedirectCancelled } return None diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt index a585f5a5470ca..fe2b645a68055 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTraversal.kt @@ -24,7 +24,9 @@ import androidx.compose.ui.focus.FocusDirection.Companion.Next import androidx.compose.ui.focus.FocusDirection.Companion.Previous import androidx.compose.ui.focus.FocusDirection.Companion.Right import androidx.compose.ui.focus.FocusDirection.Companion.Up +import androidx.compose.ui.focus.FocusRequester.Companion.Cancel import androidx.compose.ui.focus.FocusRequester.Companion.Default +import androidx.compose.ui.focus.FocusRequester.Companion.Redirect import androidx.compose.ui.focus.FocusStateImpl.Active import androidx.compose.ui.focus.FocusStateImpl.ActiveParent import androidx.compose.ui.focus.FocusStateImpl.Captured @@ -72,11 +74,26 @@ internal fun FocusTargetNode.customFocusSearch( // the user presses dPad center. (They can also redirect the "In" to some other item). // Developers can specify a custom "Out" to specify which composable should take focus // when the user presses the back button. - Enter -> { - focusProperties.enter(focusDirection) - } + Enter, Exit -> { - focusProperties.exit(focusDirection) + val scope = CancelIndicatingFocusBoundaryScope(focusDirection) + with(focusProperties) { + val focusTransactionManager = focusTransactionManager + val generationBefore = focusTransactionManager?.generation ?: 0 + if (focusDirection == Enter) { + scope.onEnter() + } else { + scope.onExit() + } + val generationAfter = focusTransactionManager?.generation ?: 0 + if (scope.isCanceled) { + Cancel + } else if (generationBefore != generationAfter) { + Redirect + } else { + Default + } + } } else -> error("invalid FocusDirection") } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/ImageVector.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/ImageVector.kt index 1329879b2c202..85b3e679a36ef 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/ImageVector.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/ImageVector.kt @@ -25,10 +25,9 @@ import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.internal.checkPrecondition +import androidx.compose.ui.platform.makeSynchronizedObject import androidx.compose.ui.platform.synchronized import androidx.compose.ui.unit.Dp -import kotlinx.atomicfu.locks.SynchronizedObject -import kotlinx.atomicfu.locks.synchronized /** * Vector graphics object that is generated as a result of [ImageVector.Builder] It can be composed @@ -379,10 +378,10 @@ internal constructor( companion object { private var imageVectorCount = 0 - private val sync = SynchronizedObject() + private val lock = makeSynchronizedObject(this) internal fun generateImageVectorId(): Int { - synchronized(sync) { + synchronized(lock) { return imageVectorCount++ } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.kt index 62029cf1877d1..336127df0e557 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.kt @@ -25,13 +25,46 @@ value class HapticFeedbackType(internal val value: Int) { override fun toString(): String { return when (this) { + Confirm -> "Confirm" + ContextClick -> "ContextClick" + GestureEnd -> "GestureEnd" + GestureThresholdActivate -> "GestureThresholdActivate" LongPress -> "LongPress" + Reject -> "Reject" + SegmentFrequentTick -> "SegmentFrequentTick" + SegmentTick -> "SegmentTick" TextHandleMove -> "TextHandleMove" + ToggleOff -> "ToggleOff" + ToggleOn -> "ToggleOn" + VirtualKey -> "VirtualKey" else -> "Invalid" } } companion object { + /** + * A haptic effect to signal the confirmation or successful completion of a user + * interaction.. + */ + val Confirm + get() = PlatformHapticFeedbackType.Confirm + + /** The user has performed a context click on an object. */ + val ContextClick + get() = PlatformHapticFeedbackType.ContextClick + + /** The user has finished a gesture (e.g. on the soft keyboard). */ + val GestureEnd + get() = PlatformHapticFeedbackType.GestureEnd + + /** + * The user is executing a swipe/drag-style gesture, such as pull-to-refresh, where the + * gesture action is eligible at a certain threshold of movement, and can be cancelled by + * moving back past the threshold. + */ + val GestureThresholdActivate + get() = PlatformHapticFeedbackType.GestureThresholdActivate + /** * The user has performed a long press on an object that is resulting in an action being * performed. @@ -39,16 +72,70 @@ value class HapticFeedbackType(internal val value: Int) { val LongPress get() = PlatformHapticFeedbackType.LongPress + /** A haptic effect to signal the rejection or failure of a user interaction. */ + val Reject + get() = PlatformHapticFeedbackType.Reject + + /** + * The user is switching between a series of many potential choices, for example minutes on + * a clock face, or individual percentages. + */ + val SegmentFrequentTick + get() = PlatformHapticFeedbackType.SegmentFrequentTick + + /** + * The user is switching between a series of potential choices, for example items in a list + * or discrete points on a slider. + */ + val SegmentTick + get() = PlatformHapticFeedbackType.SegmentTick + /** The user has performed a selection/insertion handle move on text field. */ val TextHandleMove get() = PlatformHapticFeedbackType.TextHandleMove + /** The user has toggled a switch or button into the off position. */ + val ToggleOff + get() = PlatformHapticFeedbackType.ToggleOff + + /** The user has toggled a switch or button into the on position. */ + val ToggleOn + get() = PlatformHapticFeedbackType.ToggleOn + + /** The user has pressed on a virtual on-screen key. */ + val VirtualKey + get() = PlatformHapticFeedbackType.VirtualKey + /** Returns a list of possible values of [HapticFeedbackType]. */ - fun values(): List = listOf(LongPress, TextHandleMove) + fun values(): List = + listOf( + Confirm, + ContextClick, + GestureEnd, + GestureThresholdActivate, + LongPress, + Reject, + SegmentFrequentTick, + SegmentTick, + TextHandleMove, + ToggleOff, + ToggleOn, + VirtualKey, + ) } } internal expect object PlatformHapticFeedbackType { + val Confirm: HapticFeedbackType + val ContextClick: HapticFeedbackType + val GestureEnd: HapticFeedbackType + val GestureThresholdActivate: HapticFeedbackType val LongPress: HapticFeedbackType + val Reject: HapticFeedbackType + val SegmentFrequentTick: HapticFeedbackType + val SegmentTick: HapticFeedbackType val TextHandleMove: HapticFeedbackType + val ToggleOn: HapticFeedbackType + val ToggleOff: HapticFeedbackType + val VirtualKey: HapticFeedbackType } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifier.kt index 5e82355ed43bf..eeb14e05a9950 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifier.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifier.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.input.nestedscroll +import androidx.compose.ui.ComposeUiFlags.NewNestedScrollFlingDispatchingEnabled +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.internal.JvmDefaultWithCompatibility @@ -107,7 +109,7 @@ class NestedScrollDispatcher { internal var nestedScrollNode: NestedScrollNode? = null // caches last known parent for fling clean up use. - internal var lastKnownValidParentNode: NestedScrollNode? = null + internal var lastKnownParentNode: NestedScrollNode? = null // lambda to calculate the most outer nested scroll scope for this dispatcher on demand internal var calculateNestedScrollScope: () -> CoroutineScope? = { scope } @@ -208,15 +210,17 @@ class NestedScrollDispatcher { * @param available velocity that is left for ancestors to consume * @return velocity that has been consumed by all the ancestors */ + @OptIn(ExperimentalComposeUiApi::class) suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity { - // lastKnownValidParentNode can be used to send clean up signals. + // lastKnownParentNode can be used to send clean up signals. // If this dispatcher's regular parent is not present it means either it never attached or // it was detached. If it was detached we have information about its last known parent so // we use it to send the post fling signal. We don't need to do the same for the other // methods because the problem with parity in this API comes from a node that detaches - // during a fling. - return if (parent == null) { - lastKnownValidParentNode?.onPostFling(consumed, available) ?: Velocity.Zero + // during a fling. By the time a node detaches it already sent the onPreFling event and + // consumers of Nested Scroll might expect an onPostFling event to close the cycle. + return if (parent == null && NewNestedScrollFlingDispatchingEnabled) { + lastKnownParentNode?.onPostFling(consumed, available) ?: Velocity.Zero } else { parent?.onPostFling(consumed, available) ?: Velocity.Zero } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollNode.kt index 680033bf72351..091f333fff04f 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollNode.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.input.nestedscroll +import androidx.compose.ui.ComposeUiFlags.NewNestedScrollFlingDispatchingEnabled +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.node.DelegatableNode @@ -50,7 +52,7 @@ internal class NestedScrollNode( resolvedDispatcher = dispatcher ?: NestedScrollDispatcher() // Resolve null dispatcher } - internal var lastKnownValidParentNode: NestedScrollNode? = null + internal var lastKnownParentNode: NestedScrollNode? = null internal val parentNestedScrollNode: NestedScrollNode? get() = if (isAttached) findNearestAncestor() else null @@ -96,11 +98,17 @@ internal class NestedScrollNode( return parentPreConsumed + selfPreConsumed } + @OptIn(ExperimentalComposeUiApi::class) override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { val selfConsumed = connection.onPostFling(consumed, available) // if we receive an onPostFling after detaching this node, use the last known parent // if this parent is also detached it will send the signal through the detached parents - val parent = if (isAttached) parentConnection else lastKnownValidParentNode + val parent = + if (NewNestedScrollFlingDispatchingEnabled) { + if (isAttached) parentConnection else lastKnownParentNode + } else { + parentConnection + } val parentConsumed = parent?.onPostFling(consumed + selfConsumed, available - selfConsumed) ?: Velocity.Zero return selfConsumed + parentConsumed @@ -131,10 +139,13 @@ internal class NestedScrollNode( updateDispatcherFields() } + @OptIn(ExperimentalComposeUiApi::class) override fun onDetach() { // cache parent for detached clean up access in the dispatcher and in this node. - lastKnownValidParentNode = findNearestAttachedAncestor() - resolvedDispatcher.lastKnownValidParentNode = lastKnownValidParentNode + if (NewNestedScrollFlingDispatchingEnabled) { + lastKnownParentNode = findNearestAttachedAncestor() + resolvedDispatcher.lastKnownParentNode = lastKnownParentNode + } resetDispatcherFields() } @@ -142,11 +153,14 @@ internal class NestedScrollNode( * If the node changes (onAttach) or if the dispatcher changes (node.update). We'll need to * reset the dispatcher properties accordingly. */ + @OptIn(ExperimentalComposeUiApi::class) private fun updateDispatcherFields() { resolvedDispatcher.nestedScrollNode = this - // reset lastKnownValidParentNodes - resolvedDispatcher.lastKnownValidParentNode = null - lastKnownValidParentNode = null + if (NewNestedScrollFlingDispatchingEnabled) { + // reset lastKnownParentNodes + resolvedDispatcher.lastKnownParentNode = null + lastKnownParentNode = null + } resolvedDispatcher.calculateNestedScrollScope = { nestedCoroutineScope } resolvedDispatcher.scope = coroutineScope } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt index 1115103cb8674..258d4c98fbe4f 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt @@ -20,18 +20,24 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass.Main import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DpTouchBoundsExpansion import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.TouchBoundsExpansion import androidx.compose.ui.node.TraversableNode import androidx.compose.ui.node.TraversableNode.Companion.TraverseDescendantsAction import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.requireDensity import androidx.compose.ui.node.traverseAncestors import androidx.compose.ui.node.traverseDescendants import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalPointerIconService import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastAny -/** Represents a pointer icon to use in [Modifier.pointerHoverIcon] */ +/** + * Represents a pointer icon to use in [Modifier.pointerHoverIcon] or [Modifier.stylusHoverIcon]. + */ @Stable interface PointerIcon { @@ -64,6 +70,10 @@ internal interface PointerIconService { fun getIcon(): PointerIcon fun setIcon(value: PointerIcon?) + + fun getStylusHoverIcon(): PointerIcon? + + fun setStylusHoverIcon(value: PointerIcon?) } /** @@ -72,10 +82,10 @@ internal interface PointerIconService { * icon using this modifier. * * @sample androidx.compose.ui.samples.PointerIconSample - * @param icon The icon to set - * @param overrideDescendants when false (by default) descendants are able to set their own pointer - * icon. If true, no descendants under this parent are eligible to change the icon (it will be set - * to this [the parent's] icon). + * @param icon the icon to set + * @param overrideDescendants when false (by default), descendants are able to set their own pointer + * icon. If true, no descendants under this parent are eligible to change the icon (it will be set + * to the this (the parent's) icon). */ @Stable fun Modifier.pointerHoverIcon(icon: PointerIcon, overrideDescendants: Boolean = false) = @@ -112,15 +122,93 @@ internal data class PointerHoverIconModifierElement( internal class PointerHoverIconModifierNode( icon: PointerIcon, overrideDescendants: Boolean = false +) : HoverIconModifierNode(icon, overrideDescendants) { + /* Traversal key used with the [TraversableNode] interface to enable all the traversing + * functions (ancestor, child, subtree, and subtreeIf). + */ + override val traverseKey = "androidx.compose.ui.input.pointer.PointerHoverIcon" + + override fun isRelevantPointerType(pointerType: PointerType) = + pointerType != PointerType.Stylus && pointerType != PointerType.Eraser + + override fun displayIcon(icon: PointerIcon?) { + pointerIconService?.setIcon(icon) + } +} + +/** + * Modifier that lets a developer define a pointer icon to display when a stylus is hovered over the + * element. When [overrideDescendants] is set to true, descendants cannot override the pointer icon + * using this modifier. + * + * @param icon the icon to set + * @param overrideDescendants when false (by default), descendants are able to set their own pointer + * icon. If true, no descendants under this parent are eligible to change the icon (it will be set + * to the this (the parent's) icon). + * @param touchBoundsExpansion amount by which the element's bounds is expanded + * @sample androidx.compose.ui.samples.StylusHoverIconSample + */ +fun Modifier.stylusHoverIcon( + icon: PointerIcon, + overrideDescendants: Boolean = false, + touchBoundsExpansion: DpTouchBoundsExpansion? = null +) = + this then + StylusHoverIconModifierElement( + icon = icon, + overrideDescendants = overrideDescendants, + touchBoundsExpansion = touchBoundsExpansion + ) + +internal data class StylusHoverIconModifierElement( + val icon: PointerIcon, + val overrideDescendants: Boolean = false, + val touchBoundsExpansion: DpTouchBoundsExpansion? = null +) : ModifierNodeElement() { + override fun create() = + StylusHoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion) + + override fun update(node: StylusHoverIconModifierNode) { + node.icon = icon + node.overrideDescendants = overrideDescendants + node.dpTouchBoundsExpansion = touchBoundsExpansion + } + + override fun InspectorInfo.inspectableProperties() { + name = "stylusHoverIcon" + properties["icon"] = icon + properties["overrideDescendants"] = overrideDescendants + properties["touchBoundsExpansion"] = touchBoundsExpansion + } +} + +internal class StylusHoverIconModifierNode( + icon: PointerIcon, + overrideDescendants: Boolean = false, + touchBoundsExpansion: DpTouchBoundsExpansion? = null +) : HoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion) { + /* Traversal key used with the [TraversableNode] interface to enable all the traversing + * functions (ancestor, child, subtree, and subtreeIf). + */ + override val traverseKey = "androidx.compose.ui.input.pointer.StylusHoverIcon" + + override fun isRelevantPointerType(pointerType: PointerType) = + pointerType == PointerType.Stylus || pointerType == PointerType.Eraser + + override fun displayIcon(icon: PointerIcon?) { + pointerIconService?.setStylusHoverIcon(icon) + } +} + +internal abstract class HoverIconModifierNode( + icon: PointerIcon, + overrideDescendants: Boolean = false, + var dpTouchBoundsExpansion: DpTouchBoundsExpansion? = null ) : Modifier.Node(), TraversableNode, PointerInputModifierNode, CompositionLocalConsumerModifierNode { - /* Traversal key used with the [TraversableNode] interface to enable all the traversing - * functions (ancestor, child, subtree, and subtreeIf). - */ - override val traverseKey = "androidx.compose.ui.input.pointer.PointerHoverIcon" var icon = icon set(value) { @@ -151,7 +239,7 @@ internal class PointerHoverIconModifierNode( } // Service used to actually update the icon with the system when needed. - private val pointerIconService: PointerIconService? + protected val pointerIconService: PointerIconService? get() = currentValueOf(LocalPointerIconService) private var cursorInBoundsOfNode = false @@ -162,7 +250,7 @@ internal class PointerHoverIconModifierNode( pass: PointerEventPass, bounds: IntSize ) { - if (pass == Main) { + if (pass == Main && pointerEvent.changes.fastAny { isRelevantPointerType(it.type) }) { // Cursor within the surface area of this node's bounds if (pointerEvent.type == PointerEventType.Enter) { onEnter() @@ -199,15 +287,20 @@ internal class PointerHoverIconModifierNode( super.onDetach() } + override val touchBoundsExpansion: TouchBoundsExpansion + get() = + dpTouchBoundsExpansion?.roundToTouchBoundsExpansion(requireDensity()) + ?: TouchBoundsExpansion.None + + abstract fun isRelevantPointerType(pointerType: PointerType): Boolean + + abstract fun displayIcon(icon: PointerIcon?) + private fun displayIcon() { // If there are any ancestor that override this node, we must use that icon. Otherwise, we // use the current node's icon val iconToUse = findOverridingAncestorNode()?.icon ?: icon - pointerIconService?.setIcon(iconToUse) - } - - private fun displayDefaultIcon() { - pointerIconService?.setIcon(null) + displayIcon(iconToUse) } private fun displayIconIfDescendantsDoNotHavePriority() { @@ -239,22 +332,22 @@ internal class PointerHoverIconModifierNode( * * Note: Multiple descendant nodes may have `cursorInBoundsOfNode` set to true (for when the * cursor enters their bounds). The lowest one is the one that is the correct node for the - * mouse (see example for explanation). + * pointer (see example for explanation). * * Example: Parent node contains a child node within its visual border (both are pointer icon * nodes). - * - Mouse moves over the PARENT node triggers the pointer input handler ENTER event which sets - * `cursorInBoundsOfNode` = `true`. - * - Mouse moves over CHILD node triggers the pointer input handler ENTER event which sets + * - Pointer moves over the PARENT node triggers the pointer input handler ENTER event which + * sets `cursorInBoundsOfNode` = `true`. + * - Pointer moves over CHILD node triggers the pointer input handler ENTER event which sets * `cursorInBoundsOfNode` = `true`. * * They are both true now because the pointer input event's exit is not triggered (which would - * set cursorInBoundsOfNode` = `false`) unless the mouse moves outside the parent node. Because - * the child node is contained visually within the parent node, it is not triggered. That is why - * we need to get the lowest node with `cursorInBoundsOfNode` set to true. + * set cursorInBoundsOfNode` = `false`) unless the pointer moves outside the parent node. + * Because the child node is contained visually within the parent node, it is not triggered. + * That is why we need to get the lowest node with `cursorInBoundsOfNode` set to true. */ - private fun findDescendantNodeWithCursorInBounds(): PointerHoverIconModifierNode? { - var descendantNodeWithCursorInBounds: PointerHoverIconModifierNode? = null + private fun findDescendantNodeWithCursorInBounds(): HoverIconModifierNode? { + var descendantNodeWithCursorInBounds: HoverIconModifierNode? = null traverseDescendants { var actionForSubtreeOfCurrentNode = TraverseDescendantsAction.ContinueTraversal @@ -277,54 +370,52 @@ internal class PointerHoverIconModifierNode( private fun displayIconFromCurrentNodeOrDescendantsWithCursorInBounds() { if (!cursorInBoundsOfNode) return - var pointerHoverIconModifierNode: PointerHoverIconModifierNode = this + var hoverIconModifierNode: HoverIconModifierNode = this if (!overrideDescendants) { - findDescendantNodeWithCursorInBounds()?.let { pointerHoverIconModifierNode = it } + findDescendantNodeWithCursorInBounds()?.let { hoverIconModifierNode = it } } - pointerHoverIconModifierNode.displayIcon() + hoverIconModifierNode.displayIcon() } - private fun findOverridingAncestorNode(): PointerHoverIconModifierNode? { - var pointerHoverIconModifierNode: PointerHoverIconModifierNode? = null + private fun findOverridingAncestorNode(): HoverIconModifierNode? { + var hoverIconModifierNode: HoverIconModifierNode? = null traverseAncestors { if (it.overrideDescendants && it.cursorInBoundsOfNode) { - pointerHoverIconModifierNode = it + hoverIconModifierNode = it } // continue traversal true } - return pointerHoverIconModifierNode + return hoverIconModifierNode } /* - * Sets the icon to either the ancestor where the mouse is in its bounds (or to its + * Sets the icon to either the ancestor where the pointer is in its bounds (or to its * ancestors if one overrides it) or to a default icon. */ private fun displayIconFromAncestorNodeWithCursorInBoundsOrDefaultIcon() { - var pointerHoverIconModifierNode: PointerHoverIconModifierNode? = null + var hoverIconModifierNode: HoverIconModifierNode? = null traverseAncestors { - if (pointerHoverIconModifierNode == null && it.cursorInBoundsOfNode) { - pointerHoverIconModifierNode = it + if (hoverIconModifierNode == null && it.cursorInBoundsOfNode) { + hoverIconModifierNode = it // We should only assign a node that override its descendants if there was a node - // below it where the mouse was in bounds meaning the pointerHoverIconModifierNode - // will not be null. + // below it where the pointer was in bounds meaning the hoverIconModifierNode will + // not be null. } else if ( - pointerHoverIconModifierNode != null && - it.overrideDescendants && - it.cursorInBoundsOfNode + hoverIconModifierNode != null && it.overrideDescendants && it.cursorInBoundsOfNode ) { - pointerHoverIconModifierNode = it + hoverIconModifierNode = it } // continue traversal true } - pointerHoverIconModifierNode?.displayIcon() ?: displayDefaultIcon() + hoverIconModifierNode?.displayIcon() ?: displayIcon(null) } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt index b8a3ce63c5cb8..342b3b21e10fb 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt @@ -96,8 +96,7 @@ internal class PointerInputEventProcessor(val root: LayoutNode) { for (i in 0 until internalPointerEvent.changes.size()) { val pointerInputChange = internalPointerEvent.changes.valueAt(i) if (isHover || pointerInputChange.changedToDownIgnoreConsumed()) { - val isTouchEvent = pointerInputChange.type == PointerType.Touch - root.hitTest(pointerInputChange.position, hitResult, isTouchEvent) + root.hitTest(pointerInputChange.position, hitResult, pointerInputChange.type) if (hitResult.isNotEmpty()) { hitPathTracker.addHitPath( pointerId = pointerInputChange.id, diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt index 3f7c06cf51db9..2d979035eb9d9 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.node.requireLayoutNode import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.ViewConfiguration -import androidx.compose.ui.platform.createSynchronizedObject +import androidx.compose.ui.platform.makeSynchronizedObject import androidx.compose.ui.platform.synchronized import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -463,6 +463,9 @@ sealed interface SuspendingPointerInputModifierNode : PointerInputModifierNode { fun resetPointerInputHandler() } +// Used for multi-sleep solution within [PointerEventHandlerCoroutine.withTimeout()]. +internal const val WITH_TIMEOUT_MICRO_DELAY_MILLIS = 8L + /** * Implementation notes: This class does a lot of lifting. [PointerInputModifierNode] receives, * interprets, and, consumes [PointerInputChange]s while the state (and the coroutineScope used to @@ -546,7 +549,7 @@ internal class SuspendingPointerInputModifierNodeImpl( private val pointerHandlers = mutableVectorOf>() - private val pointerHandlersLock = createSynchronizedObject() + private val pointerHandlersLock = makeSynchronizedObject(pointerHandlers) /** * Scratch list for dispatching to handlers for a particular phase. Used to hold a copy of the @@ -880,8 +883,8 @@ internal class SuspendingPointerInputModifierNodeImpl( // input events, not treated fairly in FIFO order. The second // micro-delay reposts it to the back of the queue, after any input events // that were posted but not processed during the first delay. - delay(timeMillis - 1) - delay(1) + delay(timeMillis - WITH_TIMEOUT_MICRO_DELAY_MILLIS) + delay(WITH_TIMEOUT_MICRO_DELAY_MILLIS) pointerAwaiter?.resumeWithException( PointerEventTimeoutCancellationException(timeMillis) diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt index bee65ddbe1abf..4ab18e859b785 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt @@ -14,9 +14,6 @@ * limitations under the License. */ -@file:JvmName("LayoutCoordinatesKt") -@file:JvmMultifileClass - package androidx.compose.ui.layout import androidx.compose.ui.geometry.Offset @@ -29,8 +26,6 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastMaxOf import androidx.compose.ui.util.fastMinOf -import kotlin.jvm.JvmMultifileClass -import kotlin.jvm.JvmName /** A holder of the measured bounds for the [Layout]. */ @JvmDefaultWithCompatibility @@ -161,6 +156,17 @@ interface LayoutCoordinates { ) } + /** + * Takes a [matrix] which transforms some coordinate system `C` to local coordinates, and + * updates the matrix to transform from `C` to screen coordinates instead. + */ + @Suppress("DocumentExceptions") + fun transformToScreen(matrix: Matrix) { + throw UnsupportedOperationException( + "transformToScreen is not implemented on this LayoutCoordinates" + ) + } + /** * Returns the position in pixels of an [alignment line][AlignmentLine], or * [AlignmentLine.Unspecified] if the line is not provided. @@ -189,7 +195,7 @@ fun LayoutCoordinates.boundsInWindow(): Rect { val rootWidth = root.size.width.toFloat() val rootHeight = root.size.height.toFloat() - val bounds = boundsInRoot() + val bounds = root.localBoundingBoxOf(this) val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt index 52695a6cf5978..0e241a5080163 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt @@ -178,6 +178,10 @@ internal class LookaheadLayoutCoordinates(val lookaheadDelegate: LookaheadDelega coordinator.transformFrom(sourceCoordinates, matrix) } + override fun transformToScreen(matrix: Matrix) { + coordinator.transformToScreen(matrix) + } + override fun get(alignmentLine: AlignmentLine): Int = lookaheadDelegate.get(alignmentLine) } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnRectChangedModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnRectChangedModifier.kt new file mode 100644 index 0000000000000..443b6eedd4ee9 --- /dev/null +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnRectChangedModifier.kt @@ -0,0 +1,143 @@ +/* + * 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. + */ + +package androidx.compose.ui.layout + +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireLayoutNode +import androidx.compose.ui.node.requireOwner +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.spatial.RectInfo +import kotlinx.coroutines.DisposableHandle + +/** + * Invokes [callback] with the position of this layout node relative to the coordinate system of the + * root of the composition, as well as in screen coordinates and window coordinates. This will be + * called after layout pass. This API allows for throttling and debouncing parameters in order to + * moderate the frequency with which the callback gets invoked during high rates of change (e.g. + * scrolling). + * + * Specifying [throttleMs] will prevent [callback] from being executed more than once over that time + * period. Specifying [debounceMs] will delay the execution of [callback] until that amount of time + * has elapsed without a new position, scheduling the callback to be executed when that amount of + * time expires. + * + * Specifying 0 for both [throttleMs] and [debounceMs] will result in the callback being executed + * every time the position has changed. Specifying non-zero amounts for both will result in both + * conditions being met. Specifying a non-zero [throttleMs] but a zero [debounceMs] is equivalent to + * providing the same value for both [throttleMs] and [debounceMs]. + * + * @param throttleMs The duration, in milliseconds, to prevent [callback] from being executed more + * than once over that time period. + * @param debounceMs The duration, in milliseconds, to delay the execution of [callback] until that + * amount of time has elapsed without a new position. + * @param callback The callback to be executed. + * @see RectInfo + * @see onGloballyPositioned + * @see registerOnRectChanged + */ +@Stable +fun Modifier.onRectChanged( + throttleMs: Int = 0, + debounceMs: Int = 64, + callback: (RectInfo) -> Unit +) = this then OnRectChangedElement(throttleMs, debounceMs, callback) + +private data class OnRectChangedElement( + val throttleMs: Int, + val debounceMs: Int, + val callback: (RectInfo) -> Unit +) : ModifierNodeElement() { + override fun create() = OnRectChangedNode(throttleMs, debounceMs, callback) + + override fun update(node: OnRectChangedNode) { + node.throttleMs = throttleMs + node.debounceMs = debounceMs + node.callback = callback + node.disposeAndRegister() + } + + override fun InspectorInfo.inspectableProperties() { + name = "onRectChanged" + properties["throttleMs"] = throttleMs + properties["debounceMs"] = debounceMs + properties["callback"] = callback + } +} + +private class OnRectChangedNode( + var throttleMs: Int, + var debounceMs: Int, + var callback: (RectInfo) -> Unit, +) : Modifier.Node() { + var handle: DisposableHandle? = null + + fun disposeAndRegister() { + handle?.dispose() + handle = registerOnRectChanged(throttleMs, debounceMs, callback) + } + + override fun onAttach() { + disposeAndRegister() + } + + override fun onDetach() { + handle?.dispose() + } +} + +/** + * Registers a [callback] to be executed with the position of this modifier node relative to the + * coordinate system of the root of the composition, as well as in screen coordinates and window + * coordinates. This will be called after layout pass. This API allows for throttling and debouncing + * parameters in order to moderate the frequency with which the callback gets invoked during high + * rates of change (e.g. scrolling). + * + * Specifying [throttleMs] will prevent [callback] from being executed more than once over that time + * period. Specifying [debounceMs] will delay the execution of [callback] until that amount of time + * has elapsed without a new position. + * + * Specifying 0 for both [throttleMs] and [debounceMs] will result in the callback being executed + * every time the position has changed. Specifying non-zero amounts for both will result in both + * conditions being met. + * + * @param throttleMs The duration, in milliseconds, to prevent [callback] from being executed more + * than once over that time period. + * @param debounceMs The duration, in milliseconds, to delay the execution of [callback] until that + * amount of time has elapsed without a new position. + * @param callback The callback to be executed. + * @return an object which should be used to unregister/dispose this callback + * @see onRectChanged + */ +fun DelegatableNode.registerOnRectChanged( + throttleMs: Int, + debounceMs: Int, + callback: (RectInfo) -> Unit, +): DisposableHandle { + val layoutNode = requireLayoutNode() + val id = layoutNode.semanticsId + val rectManager = layoutNode.requireOwner().rectManager + return rectManager.registerOnRectChangedCallback( + id, + throttleMs, + debounceMs, + this, + callback, + ) +} diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt index 2edc023f8e694..b3f8d719a50a5 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt @@ -408,23 +408,22 @@ internal class LayoutNodeSubcompositionsState( } private var currentIndex = 0 - private var currentPostLookaheadIndex = 0 + private var currentApproachIndex = 0 private val nodeToNodeState = mutableScatterMapOf() // this map contains active slotIds (without precomposed or reusable nodes) private val slotIdToNode = mutableScatterMapOf() private val scope = Scope() - private val postLookaheadMeasureScope = PostLookaheadMeasureScopeImpl() + private val approachMeasureScope = ApproachMeasureScopeImpl() private val precomposeMap = mutableScatterMapOf() private val reusableSlotIdsSet = SubcomposeSlotReusePolicy.SlotIdsSet() // SlotHandles precomposed in the post-lookahead pass. - private val postLookaheadPrecomposeSlotHandleMap = - mutableScatterMapOf() + private val approachPrecomposeSlotHandleMap = mutableScatterMapOf() // Slot ids _composed_ in post-lookahead. The valid slot ids are stored between 0 and - // currentPostLookaheadIndex - 1, beyond index currentPostLookaheadIndex are obsolete ids. - private val postLookaheadComposedSlotIds = mutableVectorOf() + // currentApproachIndex - 1, beyond index currentApproachIndex are obsolete ids. + private val approachComposedSlotIds = mutableVectorOf() /** * `root.foldedChildren` list consist of: @@ -739,14 +738,14 @@ internal class LayoutNodeSubcompositionsState( scope.fontScale = fontScale if (!isLookingAhead && root.lookaheadRoot != null) { // Approach pass - currentPostLookaheadIndex = 0 - val result = postLookaheadMeasureScope.block(constraints) - val indexAfterMeasure = currentPostLookaheadIndex + currentApproachIndex = 0 + val result = approachMeasureScope.block(constraints) + val indexAfterMeasure = currentApproachIndex return createMeasureResult(result) { - currentPostLookaheadIndex = indexAfterMeasure + currentApproachIndex = indexAfterMeasure result.placeChildren() // dispose - disposeUnusedSlotsInPostLookahead() + disposeUnusedSlotsInApproach() } } else { // Lookahead pass, or the main pass if not in a lookahead scope. @@ -763,10 +762,10 @@ internal class LayoutNodeSubcompositionsState( } } - private fun disposeUnusedSlotsInPostLookahead() { - postLookaheadPrecomposeSlotHandleMap.removeIf { slotId, handle -> - val id = postLookaheadComposedSlotIds.indexOf(slotId) - if (id < 0 || id >= currentPostLookaheadIndex) { + private fun disposeUnusedSlotsInApproach() { + approachPrecomposeSlotHandleMap.removeIf { slotId, handle -> + val id = approachComposedSlotIds.indexOf(slotId) + if (id < 0 || id >= currentApproachIndex) { // Slot was not used in the latest pass of post-lookahead. handle.dispose() true @@ -805,8 +804,8 @@ internal class LayoutNodeSubcompositionsState( } makeSureStateIsConsistent() if (!slotIdToNode.containsKey(slotId)) { - // Yield ownership of PrecomposedHandle from postLookahead to the caller of precompose - postLookaheadPrecomposeSlotHandleMap.remove(slotId) + // Yield ownership of PrecomposedHandle from approach to the caller of precompose + approachPrecomposeSlotHandleMap.remove(slotId) val node = precomposeMap.getOrPut(slotId) { val reusedNode = takeNodeFromReusables(slotId) @@ -959,8 +958,7 @@ internal class LayoutNodeSubcompositionsState( } } - private inner class PostLookaheadMeasureScopeImpl : - SubcomposeMeasureScope, MeasureScope by scope { + private inner class ApproachMeasureScopeImpl : SubcomposeMeasureScope, MeasureScope by scope { /** * This function retrieves [Measurable]s created for [slotId] based on the subcomposition * that happened in the lookahead pass. If [slotId] was not subcomposed in the lookahead @@ -970,31 +968,31 @@ internal class LayoutNodeSubcompositionsState( val nodeInSlot = slotIdToNode[slotId] if (nodeInSlot != null && root.foldedChildren.indexOf(nodeInSlot) < currentIndex) { // Check that the node has been composed in lookahead. Otherwise, we need to - // compose the node in approach pass via postLookaheadSubcompose. + // compose the node in approach pass via approachSubcompose. return nodeInSlot.childMeasurables } else { - return postLookaheadSubcompose(slotId, content) + return approachSubcompose(slotId, content) } } } - private fun postLookaheadSubcompose( + private fun approachSubcompose( slotId: Any?, content: @Composable () -> Unit ): List { - requirePrecondition(postLookaheadComposedSlotIds.size >= currentPostLookaheadIndex) { - "Error: currentPostLookaheadIndex cannot be greater than the size of the" + - "postLookaheadComposedSlotIds list." + requirePrecondition(approachComposedSlotIds.size >= currentApproachIndex) { + "Error: currentApproachIndex cannot be greater than the size of the" + + "approachComposedSlotIds list." } - if (postLookaheadComposedSlotIds.size == currentPostLookaheadIndex) { - postLookaheadComposedSlotIds.add(slotId) + if (approachComposedSlotIds.size == currentApproachIndex) { + approachComposedSlotIds.add(slotId) } else { - postLookaheadComposedSlotIds[currentPostLookaheadIndex] = slotId + approachComposedSlotIds[currentApproachIndex] = slotId } - currentPostLookaheadIndex++ + currentApproachIndex++ if (!precomposeMap.contains(slotId)) { // Not composed yet - precompose(slotId, content).also { postLookaheadPrecomposeSlotHandleMap[slotId] = it } + precompose(slotId, content).also { approachPrecomposeSlotHandleMap[slotId] = it } if (root.layoutState == LayoutState.LayingOut) { root.requestLookaheadRelayout(true) } else { diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt index f1bcd0df64a6c..d0ada4b3ce356 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt @@ -82,7 +82,7 @@ internal inline fun DelegatableNode.visitAncestors( // TODO(lmr): we might want to add some safety wheels to prevent this from being called // while one of the chains is being diffed / updated. Although that might only be // necessary for visiting subtree. - check(node.isAttached) { "visitAncestors called on an unattached node" } + checkPrecondition(node.isAttached) { "visitAncestors called on an unattached node" } var node: Modifier.Node? = if (includeSelf) node else node.parent var layout: LayoutNode? = requireLayoutNode() while (layout != null) { @@ -140,7 +140,7 @@ internal inline fun DelegatableNode.visitChildren( zOrder: Boolean, block: (Modifier.Node) -> Unit ) { - check(node.isAttached) { "visitChildren called on an unattached node" } + checkPrecondition(node.isAttached) { "visitChildren called on an unattached node" } val branches = mutableVectorOf() val child = node.child if (child == null) branches.addLayoutNodeChildren(node, zOrder) else branches.add(child) diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt index e8550a82a6444..51754f6c86b13 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt @@ -16,9 +16,11 @@ package androidx.compose.ui.node +import androidx.collection.MutableLongList +import androidx.collection.MutableObjectList import androidx.compose.ui.Modifier import androidx.compose.ui.util.unpackFloat1 -import androidx.compose.ui.util.unpackInt2 +import kotlin.math.min import kotlin.math.sign /** @@ -32,13 +34,13 @@ import kotlin.math.sign * @see NodeCoordinator.hitTest */ internal class HitTestResult : List { - private var values = arrayOfNulls(16) + private var values = MutableObjectList(16) // contains DistanceAndInLayer - private var distanceFromEdgeAndInLayer = LongArray(16) + private var distanceFromEdgeAndFlags = MutableLongList(16) private var hitDepth = -1 - override var size: Int = 0 - private set + override val size: Int + get() = values.size /** * `true` when there has been a direct hit within touch bounds ([hit] called) or `false` @@ -46,7 +48,7 @@ internal class HitTestResult : List { */ fun hasHit(): Boolean { val distance = findBestHitDistance() - return distance.distance < 0f && distance.isInLayer + return distance.distance < 0f && distance.isInLayer && !distance.isInExpandedBounds } /** @@ -57,13 +59,6 @@ internal class HitTestResult : List { hitDepth = size - 1 } - private fun resizeToHitDepth() { - for (i in (hitDepth + 1)..lastIndex) { - values[i] = null - } - size = hitDepth + 1 - } - /** * Returns `true` if [distanceFromEdge] is less than the previous value passed in * [hitInMinimumTouchTarget] or [speculativeHit]. @@ -72,15 +67,15 @@ internal class HitTestResult : List { if (hitDepth == lastIndex) { return true } - val distanceAndInLayer = DistanceAndInLayer(distanceFromEdge, isInLayer) + val distanceAndFlags = DistanceAndFlags(distanceFromEdge, isInLayer) val bestDistance = findBestHitDistance() - return bestDistance > distanceAndInLayer + return bestDistance > distanceAndFlags } - private fun findBestHitDistance(): DistanceAndInLayer { - var bestDistance = DistanceAndInLayer(Float.POSITIVE_INFINITY, false) + private fun findBestHitDistance(): DistanceAndFlags { + var bestDistance = DistanceAndFlags(Float.POSITIVE_INFINITY, false) for (i in hitDepth + 1..lastIndex) { - val distance = DistanceAndInLayer(distanceFromEdgeAndInLayer[i]) + val distance = DistanceAndFlags(distanceFromEdgeAndFlags[i]) bestDistance = if (distance < bestDistance) distance else bestDistance if (bestDistance.distance < 0f && bestDistance.isInLayer) { return bestDistance @@ -97,6 +92,13 @@ internal class HitTestResult : List { hitInMinimumTouchTarget(node, -1f, isInLayer, childHitTest) } + inline fun hitInMinimumTouchTarget( + node: Modifier.Node, + distanceFromEdge: Float, + isInLayer: Boolean, + childHitTest: () -> Unit + ) = hitInMinimumTouchTarget(node, distanceFromEdge, isInLayer, false, childHitTest) + /** * Records [node] as a hit with [distanceFromEdge] distance, replacing any existing record. Runs * [childHitTest] to do further hit testing for children. @@ -105,19 +107,59 @@ internal class HitTestResult : List { node: Modifier.Node, distanceFromEdge: Float, isInLayer: Boolean, + isInExpandedBounds: Boolean, childHitTest: () -> Unit ) { val startDepth = hitDepth + removeNodesInRange(hitDepth + 1, size) hitDepth++ - ensureContainerSize() - values[hitDepth] = node - distanceFromEdgeAndInLayer[hitDepth] = - DistanceAndInLayer(distanceFromEdge, isInLayer).packedValue - resizeToHitDepth() + values.add(node) + distanceFromEdgeAndFlags.add( + DistanceAndFlags(distanceFromEdge, isInLayer, isInExpandedBounds).packedValue + ) childHitTest() hitDepth = startDepth } + /** + * Records a hit in an expanded touch bound. It's similar to a speculative hit, except that if + * the previous hit is also in expanded touch, it'll share the pointer. + */ + fun hitExpandedTouchBounds(node: Modifier.Node, isInLayer: Boolean, childHitTest: () -> Unit) { + if (hitDepth == lastIndex) { + // No hit test on siblings yet, simply record hit on this node. + hitInMinimumTouchTarget(node, 0f, isInLayer, isInExpandedBounds = true, childHitTest) + return + } + + val previousDistance = findBestHitDistance() + val previousHitDepth = hitDepth + + if (previousDistance.isInExpandedBounds) { + // Previous hits was in expanded bounds. Share the pointer unless the childHitTest has + // a direct hit. + // Accept the previous hit result for now, and shuffle the array only when we have a + // direct hit. + hitDepth = lastIndex + hitInMinimumTouchTarget(node, 0f, isInLayer, isInExpandedBounds = true, childHitTest) + val newDistance = findBestHitDistance() + if (newDistance.distance < 0) { + // Have a direct hit, remove the previous hit result. + val startIndex = previousHitDepth + 1 + val endIndex = hitDepth + 1 + removeNodesInRange(startIndex, endIndex) + } + hitDepth = previousHitDepth + } else if (previousDistance.distance > 0) { + // Previous hit is out of expanded bounds, clear the previous hit and record a hit for + // this node. + hitInMinimumTouchTarget(node, 0f, isInLayer, isInExpandedBounds = true, childHitTest) + } + // If previous hit is a direct hit on sibling, do nothing. + // This case should never happen, because when a node gets a direct hit, siblingHitTest will + // stop the hit test for other children. + } + /** * Temporarily records [node] as a hit with [distanceFromEdge] distance and calls [childHitTest] * to record hits for children. If no children have hits, then the hit is discarded. If a child @@ -132,43 +174,65 @@ internal class HitTestResult : List { if (hitDepth == lastIndex) { // Speculation is easy. We don't have to do any array shuffling. hitInMinimumTouchTarget(node, distanceFromEdge, isInLayer, childHitTest) - if (hitDepth + 1 == lastIndex) { - // Discard the hit because there were no child hits. - resizeToHitDepth() + if (hitDepth + 1 == lastIndex || findBestHitDistance().isInExpandedBounds) { + // Discard the hit because there were no child hits or the child is hit in expanded + // touch bounds. Removing this node at hitDepth + 1 works for both cases. + // A parent can't intercept the event if child is at its expanded touch bounds. + // Note: We don't need to check whether this node intercepts child events, + // because speculativeHit() is only called when + // node.interceptOutOfBoundsChildEvents() returns true. + removeNodeAtDepth(hitDepth + 1) } return } - // We have to tack the speculation to the end of the array + // We have to track the speculation to the end of the array val previousDistance = findBestHitDistance() val previousHitDepth = hitDepth hitDepth = lastIndex hitInMinimumTouchTarget(node, distanceFromEdge, isInLayer, childHitTest) - if (hitDepth + 1 < lastIndex && previousDistance > findBestHitDistance()) { - // This was a successful hit, so we should move this to the previous hit depth - val fromIndex = hitDepth + 1 - val toIndex = previousHitDepth + 1 - values.copyInto( - destination = values, - destinationOffset = toIndex, - startIndex = fromIndex, - endIndex = size - ) - distanceFromEdgeAndInLayer.copyInto( - destination = distanceFromEdgeAndInLayer, - destinationOffset = toIndex, - startIndex = fromIndex, - endIndex = size - ) - - // Discard the remainder of the hits - hitDepth = previousHitDepth + size - hitDepth - 1 + val newBestDistance = findBestHitDistance() + if (hitDepth + 1 < lastIndex && previousDistance > newBestDistance) { + // This was a successful hit, so we should remove the previous hit from the hit path. + val startIndex = previousHitDepth + 1 + val endIndex = + if (newBestDistance.isInExpandedBounds) { + // If the new hit is in expanded touch bounds, this node can't intercept the + // event. We'll remove this node as well. + hitDepth + 2 + } else { + hitDepth + 1 + } + removeNodesInRange(startIndex, endIndex) + } else { + // Previous hit is better, remove this hit result from the hit path. + removeNodesInRange(hitDepth + 1, size) } - resizeToHitDepth() hitDepth = previousHitDepth } + /** + * Util method to remove a node at the given depth. The given depth must be in the range of [0, + * size). + */ + private fun removeNodeAtDepth(depth: Int) { + values.removeAt(depth) + distanceFromEdgeAndFlags.removeAt(depth) + } + + /** Util method to remove nodes at the given depth range. */ + private fun removeNodesInRange(startDepth: Int, endDepth: Int) { + if (startDepth >= endDepth) { + return + } + values.removeRange(start = startDepth, end = endDepth) + distanceFromEdgeAndFlags.removeRange( + start = startDepth, + end = endDepth, + ) + } + /** * Allow multiple sibling children to have a target hit within a Layout. Use [acceptHits] within * [block] to mark a child's hits as accepted and proceed to hit test more children. @@ -179,14 +243,6 @@ internal class HitTestResult : List { hitDepth = depth } - private fun ensureContainerSize() { - if (hitDepth >= values.size) { - val newSize = values.size + 16 - values = values.copyOf(newSize) - distanceFromEdgeAndInLayer = distanceFromEdgeAndInLayer.copyOf(newSize) - } - } - override fun contains(element: Modifier.Node): Boolean = indexOf(element) != -1 override fun containsAll(elements: Collection): Boolean { @@ -209,7 +265,7 @@ internal class HitTestResult : List { return -1 } - override fun isEmpty(): Boolean = size == 0 + override fun isEmpty(): Boolean = values.isEmpty() override fun iterator(): Iterator = HitTestResultIterator() @@ -233,7 +289,8 @@ internal class HitTestResult : List { /** Clears all entries to make an empty list. */ fun clear() { hitDepth = -1 - resizeToHitDepth() + values.clear() + distanceFromEdgeAndFlags.clear() } private inner class HitTestResultIterator( @@ -307,27 +364,46 @@ internal class HitTestResult : List { } } +private const val IS_IN_LAYER: Long = 1L +private const val IS_IN_EXPANDED_BOUNDS: Long = 1 shl 1 + @kotlin.jvm.JvmInline -internal value class DistanceAndInLayer(val packedValue: Long) { +internal value class DistanceAndFlags(val packedValue: Long) { val distance: Float get() = unpackFloat1(packedValue) val isInLayer: Boolean - get() = unpackInt2(packedValue) != 0 + get() = (packedValue and IS_IN_LAYER) != 0L + + val isInExpandedBounds: Boolean + get() = (packedValue and IS_IN_EXPANDED_BOUNDS) != 0L - operator fun compareTo(other: DistanceAndInLayer): Int { + operator fun compareTo(other: DistanceAndFlags): Int { val thisIsInLayer = isInLayer val otherIsInLayer = other.isInLayer if (thisIsInLayer != otherIsInLayer) { return if (thisIsInLayer) -1 else 1 } - val distanceDiff = this.distance - other.distance - return sign(distanceDiff).toInt() + val distanceDiff = sign(this.distance - other.distance).toInt() + // One has a direct hit, use distance for comparison. + if (min(this.distance, other.distance) < 0f) { + return distanceDiff + } + // Both are out of bounds hit, the one in the expanded touch bounds wins. + if (this.isInExpandedBounds != other.isInExpandedBounds) { + return if (this.isInExpandedBounds) -1 else 1 + } + return distanceDiff } } -private fun DistanceAndInLayer(distance: Float, isInLayer: Boolean): DistanceAndInLayer { +private fun DistanceAndFlags( + distance: Float, + isInLayer: Boolean, + isInExpandedBounds: Boolean = false +): DistanceAndFlags { val v1 = distance.toRawBits().toLong() - val v2 = if (isInLayer) 1L else 0L - return DistanceAndInLayer(v1.shl(32) or (v2 and 0xFFFFFFFF)) + var v2 = if (isInLayer) IS_IN_LAYER else 0L + v2 = v2 or if (isInExpandedBounds) IS_IN_EXPANDED_BOUNDS else 0L + return DistanceAndFlags(v1.shl(32) or (v2 and 0xFFFFFFFF)) } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt index ebb06c8943ec1..2c72188059da6 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.GraphicsLayerScope import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.PaintingStyle import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.Placeable import androidx.compose.ui.unit.Constraints @@ -185,7 +186,7 @@ internal class InnerNodeCoordinator(layoutNode: LayoutNode) : NodeCoordinator(la hitTestSource: HitTestSource, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean ) { var inLayer = isInLayer @@ -195,7 +196,7 @@ internal class InnerNodeCoordinator(layoutNode: LayoutNode) : NodeCoordinator(la if (withinLayerBounds(pointerPosition)) { hitTestChildren = true } else if ( - isTouchEvent && + pointerType == PointerType.Touch && distanceInMinimumTouchTarget(pointerPosition, minimumTouchTargetSize) .fastIsFinite() ) { @@ -215,7 +216,7 @@ internal class InnerNodeCoordinator(layoutNode: LayoutNode) : NodeCoordinator(la child, pointerPosition, hitTestResult, - isTouchEvent, + pointerType, inLayer ) val wasHit = hitTestResult.hasHit() diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt index dde1a3f9242ca..4e4f8ab417080 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt @@ -19,6 +19,8 @@ import androidx.compose.runtime.ComposeNodeLifecycleCallback import androidx.compose.runtime.CompositionLocalMap import androidx.compose.runtime.collection.MutableVector import androidx.compose.runtime.collection.mutableVectorOf +import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -26,6 +28,7 @@ import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.input.pointer.PointerInputFilter import androidx.compose.ui.input.pointer.PointerInputModifier +import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.internal.checkPrecondition import androidx.compose.ui.internal.checkPreconditionNotNull import androidx.compose.ui.internal.requirePrecondition @@ -46,7 +49,6 @@ import androidx.compose.ui.node.LayoutNode.LayoutState.LayingOut import androidx.compose.ui.node.LayoutNode.LayoutState.LookaheadLayingOut import androidx.compose.ui.node.LayoutNode.LayoutState.LookaheadMeasuring import androidx.compose.ui.node.LayoutNode.LayoutState.Measuring -import androidx.compose.ui.node.Nodes.Draw import androidx.compose.ui.node.Nodes.PointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection @@ -54,6 +56,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.simpleIdentityToString import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsInfo import androidx.compose.ui.semantics.generateSemanticsId import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -88,6 +91,7 @@ internal class LayoutNode( Remeasurement, OwnerScope, LayoutInfo, + SemanticsInfo, ComposeUiNode, InteroperableComposeUiNode, Owner.OnLayoutCompletedListener { @@ -278,19 +282,8 @@ internal class LayoutNode( * then [instance] will become [attach]ed also. [instance] must have a `null` [parent]. */ internal fun insertAt(index: Int, instance: LayoutNode) { - checkPrecondition(instance._foldedParent == null) { - "Cannot insert $instance because it already has a parent." + - " This tree: " + - debugTreeToString() + - " Other tree: " + - instance._foldedParent?.debugTreeToString() - } - checkPrecondition(instance.owner == null) { - "Cannot insert $instance because it already has an owner." + - " This tree: " + - debugTreeToString() + - " Other tree: " + - instance.debugTreeToString() + checkPrecondition(instance._foldedParent == null || instance.owner == null) { + exceptionMessageForParentingOrOwnership(instance) } if (DebugChanges) { @@ -316,6 +309,13 @@ internal class LayoutNode( } } + private fun exceptionMessageForParentingOrOwnership(instance: LayoutNode) = + "Cannot insert $instance because it already has a parent or an owner." + + " This tree: " + + debugTreeToString() + + " Other tree: " + + instance._foldedParent?.debugTreeToString() + internal fun onZSortedChildrenInvalidated() { if (isVirtual) { parent?.onZSortedChildrenInvalidated() @@ -398,43 +398,62 @@ internal class LayoutNode( invalidateMeasurements() } - private var _collapsedSemantics: SemanticsConfiguration? = null + private var _semanticsConfiguration: SemanticsConfiguration? = null + override val semanticsConfiguration: SemanticsConfiguration? + get() { + // This is needed until we completely move to the new world where we always pre-compute + // the semantics configuration. At that point, this can be replaced by + // check(!isSemanticsInvalidated) or remove this custom getter. + if (isSemanticsInvalidated) { + _semanticsConfiguration = calculateSemanticsConfiguration() + } + return _semanticsConfiguration + } + + private fun calculateSemanticsConfiguration(): SemanticsConfiguration? { + if (!nodes.has(Nodes.Semantics)) return null + + var config = SemanticsConfiguration() + requireOwner().snapshotObserver.observeSemanticsReads(this) { + nodes.tailToHead(Nodes.Semantics) { + if (it.shouldClearDescendantSemantics) { + config = SemanticsConfiguration() + config.isClearingSemantics = true + } + if (it.shouldMergeDescendantSemantics) { + config.isMergingSemanticsOfDescendants = true + } + with(config) { with(it) { applySemantics() } } + } + } + return config + } + + private var isSemanticsInvalidated = false internal fun invalidateSemantics() { - _collapsedSemantics = null + if ( + @OptIn(ExperimentalComposeUiApi::class) !ComposeUiFlags.isSemanticAutofillEnabled || + nodes.isUpdating || + applyingModifierOnAttach + ) { + // We are currently updating the modifier, so just schedule an invalidation. After + // applying the modifier, we will notify listeners of semantics changes. + isSemanticsInvalidated = true + } else { + // We are not currently updating the modifier, so instead of scheduling invalidation, + // we update the semantics configuration and send the notification event right away. + val prev = _semanticsConfiguration + _semanticsConfiguration = calculateSemanticsConfiguration() + requireOwner().semanticsOwner.notifySemanticsChange(this, prev) + } + // TODO(lmr): this ends up scheduling work that diffs the entire tree, but we should // eventually move to marking just this node as invalidated since we are invalidating // on a per-node level. This should preserve current behavior for now. requireOwner().onSemanticsChange() } - internal val collapsedSemantics: SemanticsConfiguration? - get() { - // TODO: investigate if there's a better way to approach "half attached" state and - // whether or not deactivated nodes should be considered removed or not. - if (!isAttached || isDeactivated) return null - - if (!nodes.has(Nodes.Semantics) || _collapsedSemantics != null) { - return _collapsedSemantics - } - - var config = SemanticsConfiguration() - requireOwner().snapshotObserver.observeSemanticsReads(this) { - nodes.tailToHead(Nodes.Semantics) { - if (it.shouldClearDescendantSemantics) { - config = SemanticsConfiguration() - config.isClearingSemantics = true - } - if (it.shouldMergeDescendantSemantics) { - config.isMergingSemanticsOfDescendants = true - } - with(config) { with(it) { applySemantics() } } - } - } - _collapsedSemantics = config - return config - } - /** * Set the [Owner] of this LayoutNode. This LayoutNode must not already be attached. [owner] * must match its [parent].[owner]. @@ -467,9 +486,11 @@ internal class LayoutNode( pendingModifier?.let { applyModifier(it) } pendingModifier = null - if (nodes.has(Nodes.Semantics)) { + @OptIn(ExperimentalComposeUiApi::class) + if (!ComposeUiFlags.isSemanticAutofillEnabled && nodes.has(Nodes.Semantics)) { invalidateSemantics() } + owner.onAttach(this) // Update lookahead root when attached. For nested cases, we'll always use the @@ -488,6 +509,12 @@ internal class LayoutNode( if (!isDeactivated) { nodes.markAsAttached() } + + @OptIn(ExperimentalComposeUiApi::class) + if (ComposeUiFlags.isSemanticAutofillEnabled && nodes.has(Nodes.Semantics)) { + invalidateSemantics() + } + _foldedChildren.forEach { child -> child.attach(owner) } if (!isDeactivated) { nodes.runAttachLifecycle() @@ -521,9 +548,10 @@ internal class LayoutNode( } layoutDelegate.resetAlignmentLines() onDetach?.invoke(owner) - if (nodes.has(Nodes.Semantics)) { - invalidateSemantics() + _semanticsConfiguration = null + isSemanticsInvalidated = false + requireOwner().onSemanticsChange() } nodes.runDetachLifecycle() ignoreRemeasureRequests { _foldedChildren.forEach { child -> child.detach() } } @@ -559,6 +587,10 @@ internal class LayoutNode( return _zSortedChildren } + @Suppress("UNCHECKED_CAST") + override val childrenInfo: MutableVector + get() = zSortedChildren as MutableVector + override val isValidOwnerScope: Boolean get() = isAttached @@ -870,6 +902,14 @@ internal class LayoutNode( if (lookaheadRoot == null && nodes.has(Nodes.ApproachMeasure)) { lookaheadRoot = this } + // Notify semantics listeners if semantics was invalidated. + @OptIn(ExperimentalComposeUiApi::class) + if (ComposeUiFlags.isSemanticAutofillEnabled && isSemanticsInvalidated) { + val prev = _semanticsConfiguration + _semanticsConfiguration = calculateSemanticsConfiguration() + isSemanticsInvalidated = false + requireOwner().semanticsOwner.notifySemanticsChange(this, prev) + } } private fun resetModifierState() { @@ -945,7 +985,7 @@ internal class LayoutNode( internal fun hitTest( pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean = false, + pointerType: PointerType = PointerType.Unknown, isInLayer: Boolean = true ) { val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition) @@ -953,7 +993,7 @@ internal class LayoutNode( NodeCoordinator.PointerInputSource, positionInWrapped, hitTestResult, - isTouchEvent, + pointerType, isInLayer ) } @@ -962,7 +1002,7 @@ internal class LayoutNode( internal fun hitTestSemantics( pointerPosition: Offset, hitSemanticsEntities: HitTestResult, - isTouchEvent: Boolean = true, + pointerType: PointerType = PointerType.Touch, isInLayer: Boolean = true ) { val positionInWrapped = outerCoordinator.fromParentPosition(pointerPosition) @@ -970,7 +1010,7 @@ internal class LayoutNode( NodeCoordinator.SemanticsSource, positionInWrapped, hitSemanticsEntities, - isTouchEvent = true, + pointerType = PointerType.Touch, isInLayer = isInLayer ) } @@ -1219,19 +1259,6 @@ internal class LayoutNode( } } - private fun shouldInvalidateParentLayer(): Boolean { - if (nodes.has(Nodes.Draw) && !nodes.has(Nodes.Layout)) return true - nodes.headToTail { - if (it.isKind(Nodes.Layout)) { - if (it.requireCoordinator(Nodes.Layout).layer != null) { - return false - } - } - if (it.isKind(Nodes.Draw)) return true - } - return true - } - /** * Walks the subtree and clears all [intrinsicsUsageByParent] that this LayoutNode's measurement * used intrinsics on. @@ -1287,7 +1314,7 @@ internal class LayoutNode( } } - override val parentInfo: LayoutInfo? + override val parentInfo: SemanticsInfo? get() = parent override var isDeactivated = false @@ -1299,7 +1326,6 @@ internal class LayoutNode( subcompositionsState?.onReuse() if (isDeactivated) { isDeactivated = false - invalidateSemantics() // we don't need to reset state as it was done when deactivated } else { resetModifierState() @@ -1308,6 +1334,7 @@ internal class LayoutNode( semanticsId = generateSemanticsId() nodes.markAsAttached() nodes.runAttachLifecycle() + if (nodes.has(Nodes.Semantics)) invalidateSemantics() rescheduleRemeasureOrRelayout(this) } @@ -1316,10 +1343,8 @@ internal class LayoutNode( subcompositionsState?.onDeactivate() isDeactivated = true resetModifierState() - // if the node is detached the semantics were already updated without this node. - if (isAttached) { - invalidateSemantics() - } + _semanticsConfiguration = null + isSemanticsInvalidated = false owner?.onLayoutNodeDeactivated(this) } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt index 9972f90691bdd..231fc1c410e94 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt @@ -577,6 +577,12 @@ internal class MeasureAndLayoutDelegate(private val root: LayoutNode) { layoutNode.replace() } onPositionedDispatcher.onNodePositioned(layoutNode) + // Since there has been an update to a coordinator somewhere in the + // modifier chain of this layout node, we might have onRectChanged + // callbacks that need to be notified of that change. As a result, even + // if the outer rect of this layout node hasn't changed, we want to + // invalidate the callbacks for them + layoutNode.requireOwner().rectManager.invalidateCallbacksFor(layoutNode) consistencyChecker?.assertConsistent() } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt index 4d72e334a43d2..c08a9293bcc90 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.areObjectsOfSameType import androidx.compose.ui.internal.checkPrecondition import androidx.compose.ui.internal.checkPreconditionNotNull +import androidx.compose.ui.internal.throwIllegalStateException import androidx.compose.ui.layout.ModifierInfo private val SentinelHead = @@ -39,8 +40,8 @@ internal class NodeChain(val layoutNode: LayoutNode) { internal var head: Modifier.Node = tail private set - private val isUpdating: Boolean - get() = head === SentinelHead + internal val isUpdating: Boolean + get() = head.parent != null private val aggregateChildKindSet: Int get() = head.aggregateChildKindSet @@ -289,6 +290,15 @@ internal class NodeChain(val layoutNode: LayoutNode) { * invalidations as a result of the attach, if needed. */ fun runAttachLifecycle() { + // Assign layers that have been recycled in onDetach() in case the node has been reused + var coordinator: NodeCoordinator = outerCoordinator + val innerCoordinator = innerCoordinator + while (coordinator !== innerCoordinator) { + coordinator.onAttach() + coordinator = coordinator.wrapped!! + } + innerCoordinator.onAttach() + headToTail { it.runAttachLifecycle() if (it.insertedNodeAwaitingAttachForInvalidation) { @@ -358,6 +368,15 @@ internal class NodeChain(val layoutNode: LayoutNode) { internal fun runDetachLifecycle() { tailToHead { if (it.isAttached) it.runDetachLifecycle() } + + // Recycle layers + var coordinator: NodeCoordinator = innerCoordinator + val outerCoordinator = outerCoordinator + while (coordinator !== outerCoordinator) { + coordinator.onDetach() + coordinator = coordinator.wrappedBy!! + } + outerCoordinator.onDetach() } private fun getDiffer( @@ -565,47 +584,6 @@ internal class NodeChain(val layoutNode: LayoutNode) { return parent!! } - private fun createAndInsertNodeAsParent( - element: Modifier.Element, - child: Modifier.Node, - ): Modifier.Node { - val node = - when (element) { - is ModifierNodeElement<*> -> - element.create().also { - it.kindSet = calculateNodeKindSetFromIncludingDelegates(it) - } - else -> BackwardsCompatNode(element) - } - checkPrecondition(!node.isAttached) { - "createAndInsertNodeAsParent called on an attached node" - } - node.insertedNodeAwaitingAttachForInvalidation = true - return insertParent(node, child) - } - - /** - * This inserts [node] as the parent of [child] in the current linked list. For example: - * - * Head... -> child -> ...Tail - * - * gets transformed into a list of the following shape: - * - * Head... -> node -> child -> ...Tail - * - * @return The inserted [node] - */ - private fun insertParent(node: Modifier.Node, child: Modifier.Node): Modifier.Node { - val theParent = child.parent - if (theParent != null) { - theParent.child = node - node.parent = theParent - } - child.parent = node - node.child = child - return node - } - private fun createAndInsertNodeAsChild( element: Modifier.Element, parent: Modifier.Node, @@ -669,7 +647,7 @@ internal class NodeChain(val layoutNode: LayoutNode) { node.updatedNodeAwaitingAttachForInvalidation = true } } - else -> error("Unknown Modifier.Node type") + else -> throwIllegalStateException("Unknown Modifier.Node type") } } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt index 4af9cb73412e6..fdd8cbeffb6af 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isFinite +import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.DefaultCameraDistance @@ -34,6 +35,8 @@ import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.ReusableGraphicsLayerScope import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.input.pointer.MatrixPositionCalculator +import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.internal.checkPrecondition import androidx.compose.ui.internal.checkPreconditionNotNull import androidx.compose.ui.internal.requirePrecondition @@ -45,6 +48,7 @@ import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.layout.positionOnScreen import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset @@ -613,6 +617,27 @@ internal abstract class NodeCoordinator( val minimumTouchTargetSize: Size get() = with(layerDensity) { layoutNode.viewConfiguration.minimumTouchTargetSize.toSize() } + fun onAttach() { + if (layer == null && layerBlock != null) { + // This has been detached and is now being reattached. It previously had a layer, so + // reconstitute one. + layer = + layoutNode + .requireOwner() + .createLayer(drawBlock, invalidateParentLayer, explicitLayer) + .apply { + resize(measuredSize) + move(position) + invalidate() + } + } + } + + fun onDetach() { + layer?.destroy() + layer = null + } + /** * Executes a hit test for this [NodeCoordinator]. * @@ -620,25 +645,25 @@ internal abstract class NodeCoordinator( * @param pointerPosition The tested pointer position, which is relative to the * [NodeCoordinator]. * @param hitTestResult The parent [HitTestResult] that any hit should be added to. - * @param isTouchEvent `true` if this is from a touch source. Touch sources allow for minimum + * @param pointerType The [PointerType] of the source input. Touch sources allow for minimum * touch target. Semantics hit tests always treat hits as needing minimum touch target. * @param isInLayer `true` if the touch event is in the layer of this and all parents or `false` * if it is outside the layer, but within the minimum touch target of the edge of the layer. - * This can only be `false` when [isTouchEvent] is `true` or else a layer miss means the event - * will be clipped out. + * This can only be `false` when [pointerType] is [PointerType.Touch] or else a layer miss + * means the event will be clipped out. */ fun hitTest( hitTestSource: HitTestSource, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean ) { val head = head(hitTestSource.entityType()) if (!withinLayerBounds(pointerPosition)) { // This missed the clip, but if this layout is too small and this is within the // minimum touch target, we still consider it a hit. - if (isTouchEvent) { + if (pointerType == PointerType.Touch) { val distanceFromEdge = distanceInMinimumTouchTarget(pointerPosition, minimumTouchTargetSize) if ( @@ -649,47 +674,36 @@ internal abstract class NodeCoordinator( hitTestSource, pointerPosition, hitTestResult, - isTouchEvent, + pointerType, false, distanceFromEdge ) } // else it is a complete miss. } } else if (head == null) { - hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer) + hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer) } else if (isPointerInBounds(pointerPosition)) { // A real hit - head.hit(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer) + head.hit(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer) } else { val distanceFromEdge = - if (!isTouchEvent) Float.POSITIVE_INFINITY + if (pointerType != PointerType.Touch) Float.POSITIVE_INFINITY else { distanceInMinimumTouchTarget(pointerPosition, minimumTouchTargetSize) } - - if ( + val isHitInMinimumTouchTargetBetter = distanceFromEdge.fastIsFinite() && hitTestResult.isHitInMinimumTouchTargetBetter(distanceFromEdge, isInLayer) - ) { - // Hit closer than existing handlers, so just record it - head.hitNear( - hitTestSource, - pointerPosition, - hitTestResult, - isTouchEvent, - isInLayer, - distanceFromEdge - ) - } else { - head.speculativeHit( - hitTestSource, - pointerPosition, - hitTestResult, - isTouchEvent, - isInLayer, - distanceFromEdge - ) - } + + head.outOfBoundsHit( + hitTestSource, + pointerPosition, + hitTestResult, + pointerType, + isInLayer, + distanceFromEdge, + isHitInMinimumTouchTargetBetter + ) } } @@ -700,19 +714,83 @@ internal abstract class NodeCoordinator( hitTestSource: HitTestSource, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean ) { if (this == null) { - hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer) + hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer) } else { hitTestResult.hit(this, isInLayer) { nextUntil(hitTestSource.entityType(), Nodes.Layout) - .hit(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer) + .hit(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer) } } } + /** + * The pointer lands outside the node's bounds. There are three cases we have to handle: + * 1. hitNear: if the nodes is smaller than the minimumTouchTargetSize, it's touch bounds will + * be expanded to the minimal touch target size. + * 2. hitExpandedTouchBounds: if the nodes has a expanded touch bounds. + * 3. speculativeHit: if the hit misses this node, but its child can still get the pointer + * event. + * + * The complication is when touch bounds overlaps, there are 3 possibilities: + * 1. hit in this node's expanded touch bounds or minimum touch target bounds overlaps with a + * direct hit in the other node. The node with direct hit will get the event. + * 2. hit in this node's expanded touch bounds overlaps with other node's expanded touch bounds. + * Both nodes will get the event. + * 3. hit in this node's expanded touch bounds overlaps with the other node's minimum touch + * touch bounds. The node with expanded touch bounds will get the event. + * + * The logic to handle the hit priority is implemented in [HitTestResult.speculativeHit] and + * [HitTestResult.hitExpandedTouchBounds]. + */ + private fun Modifier.Node?.outOfBoundsHit( + hitTestSource: HitTestSource, + pointerPosition: Offset, + hitTestResult: HitTestResult, + pointerType: PointerType, + isInLayer: Boolean, + distanceFromEdge: Float, + isHitInMinimumTouchTargetBetter: Boolean + ) { + if (this == null) { + hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer) + } else if (isInExpandedTouchBounds(pointerPosition, pointerType)) { + hitTestResult.hitExpandedTouchBounds(this, isInLayer) { + nextUntil(hitTestSource.entityType(), Nodes.Layout) + .outOfBoundsHit( + hitTestSource, + pointerPosition, + hitTestResult, + pointerType, + isInLayer, + distanceFromEdge, + isHitInMinimumTouchTargetBetter + ) + } + } else if (isHitInMinimumTouchTargetBetter) { + hitNear( + hitTestSource, + pointerPosition, + hitTestResult, + pointerType, + isInLayer, + distanceFromEdge + ) + } else { + speculativeHit( + hitTestSource, + pointerPosition, + hitTestResult, + pointerType, + isInLayer, + distanceFromEdge + ) + } + } + /** * The [NodeCoordinator] had a hit [distanceFromEdge] from the bounds and it is within the * minimum touch target distance, so it should be recorded as such in the [hitTestResult]. @@ -721,23 +799,24 @@ internal abstract class NodeCoordinator( hitTestSource: HitTestSource, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean, distanceFromEdge: Float ) { if (this == null) { - hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer) + hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer) } else { // Hit closer than existing handlers, so just record it hitTestResult.hitInMinimumTouchTarget(this, distanceFromEdge, isInLayer) { nextUntil(hitTestSource.entityType(), Nodes.Layout) - .hitNear( + .outOfBoundsHit( hitTestSource, pointerPosition, hitTestResult, - isTouchEvent, + pointerType, isInLayer, - distanceFromEdge + distanceFromEdge, + isHitInMinimumTouchTargetBetter = true ) } } @@ -751,45 +830,74 @@ internal abstract class NodeCoordinator( hitTestSource: HitTestSource, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean, distanceFromEdge: Float ) { if (this == null) { - hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer) + hitTestChild(hitTestSource, pointerPosition, hitTestResult, pointerType, isInLayer) } else if (hitTestSource.interceptOutOfBoundsChildEvents(this)) { // We only want to replace the existing touch target if there are better // hits in the children hitTestResult.speculativeHit(this, distanceFromEdge, isInLayer) { nextUntil(hitTestSource.entityType(), Nodes.Layout) - .speculativeHit( + .outOfBoundsHit( hitTestSource, pointerPosition, hitTestResult, - isTouchEvent, + pointerType, isInLayer, - distanceFromEdge + distanceFromEdge, + isHitInMinimumTouchTargetBetter = false ) } } else { nextUntil(hitTestSource.entityType(), Nodes.Layout) - .speculativeHit( + .outOfBoundsHit( hitTestSource, pointerPosition, hitTestResult, - isTouchEvent, + pointerType, isInLayer, - distanceFromEdge + distanceFromEdge, + isHitInMinimumTouchTargetBetter = false ) } } + /** + * Helper method to check if the pointer is inside the node's expanded touch bounds. This only + * applies to pointer input modifier nodes whose [PointerInputModifierNode.touchBoundsExpansion] + * is not null. + */ + private fun Modifier.Node?.isInExpandedTouchBounds( + pointerPosition: Offset, + pointerType: PointerType + ): Boolean { + if (this == null) { + return false + } + // The expanded touch bounds only works for stylus at this moment. + if (pointerType != PointerType.Stylus && pointerType != PointerType.Eraser) { + return false + } + dispatchForKind(Nodes.PointerInput) { + // We only check for the node itself or the first delegate PointerInputModifierNode. + val expansion = it.touchBoundsExpansion + return pointerPosition.x >= -expansion.computeLeft(layoutDirection) && + pointerPosition.x < measuredWidth + expansion.computeRight(layoutDirection) && + pointerPosition.y >= -expansion.top && + pointerPosition.y < measuredHeight + expansion.bottom + } + return false + } + /** Do a [hitTest] on the children of this [NodeCoordinator]. */ open fun hitTestChild( hitTestSource: HitTestSource, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean ) { // Also, keep looking to see if we also might hit any children. @@ -797,13 +905,7 @@ internal abstract class NodeCoordinator( val wrapped = wrapped if (wrapped != null) { val positionInWrapped = wrapped.fromParentPosition(pointerPosition) - wrapped.hitTest( - hitTestSource, - positionInWrapped, - hitTestResult, - isTouchEvent, - isInLayer - ) + wrapped.hitTest(hitTestSource, positionInWrapped, hitTestResult, pointerType, isInLayer) } } @@ -921,7 +1023,24 @@ internal abstract class NodeCoordinator( transformFromAncestor(commonAncestor, matrix) } - fun transformToAncestor(ancestor: NodeCoordinator, matrix: Matrix) { + override fun transformToScreen(matrix: Matrix) { + val owner = layoutNode.requireOwner() + val rootCoordinator = findRootCoordinates().toCoordinator() + transformToAncestor(rootCoordinator, matrix) + if (owner is MatrixPositionCalculator) { + // Only Android owner supports direct matrix manipulations, + // This API had to be Android-only in the first place. + owner.localToScreen(matrix) + } else { + // Fallback: try to extract just position + val screenPosition = rootCoordinator.positionOnScreen() + if (screenPosition.isSpecified) { + matrix.translate(screenPosition.x, screenPosition.y, 0f) + } + } + } + + private fun transformToAncestor(ancestor: NodeCoordinator, matrix: Matrix) { var wrapper = this while (wrapper != ancestor) { wrapper.layer?.transform(matrix) @@ -1325,7 +1444,7 @@ internal abstract class NodeCoordinator( layoutNode: LayoutNode, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean ) } @@ -1387,9 +1506,9 @@ internal abstract class NodeCoordinator( layoutNode: LayoutNode, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean - ) = layoutNode.hitTest(pointerPosition, hitTestResult, isTouchEvent, isInLayer) + ) = layoutNode.hitTest(pointerPosition, hitTestResult, pointerType, isInLayer) } /** Hit testing specifics for semantics. */ @@ -1400,19 +1519,19 @@ internal abstract class NodeCoordinator( override fun interceptOutOfBoundsChildEvents(node: Modifier.Node) = false override fun shouldHitTestChildren(parentLayoutNode: LayoutNode) = - parentLayoutNode.collapsedSemantics?.isClearingSemantics != true + parentLayoutNode.semanticsConfiguration?.isClearingSemantics != true override fun childHitTest( layoutNode: LayoutNode, pointerPosition: Offset, hitTestResult: HitTestResult, - isTouchEvent: Boolean, + pointerType: PointerType, isInLayer: Boolean ) = layoutNode.hitTestSemantics( pointerPosition, hitTestResult, - isTouchEvent, + pointerType, isInLayer ) } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt index 79252586f45ac..bc69573536138 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt @@ -18,11 +18,12 @@ package androidx.compose.ui.node import androidx.annotation.RestrictTo +import androidx.collection.IntObjectMap import androidx.compose.runtime.Applier import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusOwner @@ -46,6 +47,7 @@ import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -67,6 +69,9 @@ internal interface Owner : PositionCalculator { /** The root layout node in the component tree. */ val root: LayoutNode + /** A mapping of semantic id to LayoutNode. */ + val layoutNodes: IntObjectMap + /** Draw scope reused for drawing speed up. */ val sharedDrawScope: LayoutNodeDrawScope @@ -115,10 +120,10 @@ internal interface Owner : PositionCalculator { val autofill: Autofill? /** - * The [SemanticAutofill] class can be used to perform autofill operations. It is used as a + * The [AutofillManager] class can be used to perform autofill operations. It is used as a * CompositionLocal. */ - val semanticAutofill: SemanticAutofill? + val autofillManager: AutofillManager? val density: Density @@ -128,6 +133,8 @@ internal interface Owner : PositionCalculator { val pointerIconService: PointerIconService + val semanticsOwner: SemanticsOwner + /** Provide a focus owner that controls focus within Compose. */ val focusOwner: FocusOwner diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt index 403f6fbf1ddd4..ed623067e51d8 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt @@ -114,6 +114,18 @@ interface PointerInputModifierNode : DelegatableNode { fun onViewConfigurationChange() { onCancelPointerInput() } + + /** + * Override this value to expand the touch bounds of this [PointerInputModifierNode] by the + * given value in align each edge. It only applies to this pointer input modifier and won't + * impact other pointer input modifiers on the same [LayoutNode]. Also note that a pointer in + * expanded touch bounds can't be intercepted by its parents and ancestors even if their + * [interceptOutOfBoundsChildEvents] returns true. + * + * @see TouchBoundsExpansion + */ + val touchBoundsExpansion: TouchBoundsExpansion + get() = TouchBoundsExpansion.None } internal val PointerInputModifierNode.isAttached: Boolean diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt index dd3e2f831b546..002be43a99679 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt @@ -87,7 +87,14 @@ interface SemanticsModifierNode : DelegatableNode { fun SemanticsPropertyReceiver.applySemantics() } -fun SemanticsModifierNode.invalidateSemantics() = requireLayoutNode().invalidateSemantics() +/** + * Invalidate semantics associated with this node. This will reset the [SemanticsConfiguration] + * associated with the layout node backing this modifier node, and will re-calculate it the next + * time the [SemanticsConfiguration] is read. + */ +fun SemanticsModifierNode.invalidateSemantics() { + requireLayoutNode().invalidateSemantics() +} internal val SemanticsConfiguration.useMinimumTouchTarget: Boolean get() = getOrNull(SemanticsActions.OnClick) != null diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt new file mode 100644 index 0000000000000..14ba4ab1cc42c --- /dev/null +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt @@ -0,0 +1,247 @@ +/* + * 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. + */ + +package androidx.compose.ui.node + +import androidx.compose.ui.internal.requirePrecondition +import androidx.compose.ui.node.DpTouchBoundsExpansion.Companion.Absolute +import androidx.compose.ui.node.TouchBoundsExpansion.Companion.Absolute +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.jvm.JvmInline + +/** + * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges. See + * [TouchBoundsExpansion] factories and [Absolute] for convenient ways to build + * [TouchBoundsExpansion]. + * + * @see PointerInputModifierNode.touchBoundsExpansion + */ +@JvmInline +value class TouchBoundsExpansion internal constructor(private val packedValue: Long) { + companion object { + /** + * Creates a [TouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`, `top`, + * `right` and `bottom` represent the amount of pixels that the touch bounds is expanded + * along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive). + */ + fun Absolute( + left: Int = 0, + top: Int = 0, + right: Int = 0, + bottom: Int = 0 + ): TouchBoundsExpansion { + requirePrecondition(left in 0..MAX_VALUE) { + "Start must be in the range of 0 .. $MAX_VALUE" + } + requirePrecondition(top in 0..MAX_VALUE) { + "Top must be in the range of 0 .. $MAX_VALUE" + } + requirePrecondition(right in 0..MAX_VALUE) { + "End must be in the range of 0 .. $MAX_VALUE" + } + requirePrecondition(bottom in 0..MAX_VALUE) { + "Bottom must be in the range of 0 .. $MAX_VALUE" + } + return TouchBoundsExpansion(pack(left, top, right, bottom, false)) + } + + /** Constant that represents no touch bounds expansion. */ + val None = TouchBoundsExpansion(0) + + internal fun pack( + start: Int, + top: Int, + end: Int, + bottom: Int, + isLayoutDirectionAware: Boolean + ): Long { + return trimAndShift(start, 0) or + trimAndShift(top, 1) or + trimAndShift(end, 2) or + trimAndShift(bottom, 3) or + if (isLayoutDirectionAware) IS_LAYOUT_DIRECTION_AWARE else 0L + } + + private const val MASK = 0x7FFF + + private const val SHIFT = 15 + + internal const val MAX_VALUE = MASK + + private const val IS_LAYOUT_DIRECTION_AWARE = 1L shl 63 + + // We stored all + private fun unpack(packedValue: Long, position: Int): Int = + (packedValue shr (position * SHIFT)).toInt() and MASK + + private fun trimAndShift(int: Int, position: Int): Long = + (int and MASK).toLong() shl (position * SHIFT) + } + + /** + * The amount of pixels the touch bounds should be expanded along the start edge. When + * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is + * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always + * applied to the left edge. + */ + val start: Int + get() = unpack(packedValue, 0) + + /** The amount of pixels the touch bounds should be expanded along the top edge. */ + val top: Int + get() = unpack(packedValue, 1) + + /** + * The amount of pixels the touch bounds should be expanded along the end edge. When + * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is + * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always + * applied to the left edge. + */ + val end: Int + get() = unpack(packedValue, 2) + + /** The amount of pixels the touch bounds should be expanded along the bottom edge. */ + val bottom: Int + get() = unpack(packedValue, 3) + + /** + * Whether this [TouchBoundsExpansion] is aware of [LayoutDirection] or not. See [start] and + * [end] for more details. + */ + val isLayoutDirectionAware: Boolean + get() = (packedValue and IS_LAYOUT_DIRECTION_AWARE) != 0L + + /** Returns the amount of pixels the touch bounds is expanded towards left. */ + internal fun computeLeft(layoutDirection: LayoutDirection): Int { + return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) { + start + } else { + end + } + } + + /** Returns the amount of pixels the touch bounds is expanded towards right. */ + internal fun computeRight(layoutDirection: LayoutDirection): Int { + return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) { + end + } else { + start + } + } +} + +/** + * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges using + * [Dp] for units. See [DpTouchBoundsExpansion] factories and [Absolute] for convenient ways to + * build [DpTouchBoundsExpansion]. + * + * @see PointerInputModifierNode.touchBoundsExpansion + */ +data class DpTouchBoundsExpansion( + val start: Dp, + val top: Dp, + val end: Dp, + val bottom: Dp, + val isLayoutDirectionAware: Boolean +) { + init { + requirePrecondition(start.value >= 0) { "Left must be non-negative" } + requirePrecondition(top.value >= 0) { "Top must be non-negative" } + requirePrecondition(end.value >= 0) { "Right must be non-negative" } + requirePrecondition(bottom.value >= 0) { "Bottom must be non-negative" } + } + + fun roundToTouchBoundsExpansion(density: Density) = + with(density) { + TouchBoundsExpansion( + packedValue = + TouchBoundsExpansion.pack( + start.roundToPx(), + top.roundToPx(), + end.roundToPx(), + bottom.roundToPx(), + isLayoutDirectionAware + ) + ) + } + + companion object { + /** + * Creates a [DpTouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`, + * `top`, `right` and `bottom` represent the distance that the touch bounds is expanded + * along the corresponding edge. + */ + fun Absolute( + left: Dp = 0.dp, + top: Dp = 0.dp, + right: Dp = 0.dp, + bottom: Dp = 0.dp + ): DpTouchBoundsExpansion { + return DpTouchBoundsExpansion(left, top, right, bottom, false) + } + } +} + +/** + * Creates a [TouchBoundsExpansion] that's aware of [LayoutDirection]. See + * [TouchBoundsExpansion.start] and [TouchBoundsExpansion.end] for more details about + * [LayoutDirection]. + * + * The `start`, `top`, `end` and `bottom` represent the amount of pixels that the touch bounds is + * expanded along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive). + */ +fun TouchBoundsExpansion( + start: Int = 0, + top: Int = 0, + end: Int = 0, + bottom: Int = 0 +): TouchBoundsExpansion { + requirePrecondition(start in 0..TouchBoundsExpansion.MAX_VALUE) { + "Start must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}" + } + requirePrecondition(top in 0..TouchBoundsExpansion.MAX_VALUE) { + "Top must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}" + } + requirePrecondition(end in 0..TouchBoundsExpansion.MAX_VALUE) { + "End must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}" + } + requirePrecondition(bottom in 0..TouchBoundsExpansion.MAX_VALUE) { + "Bottom must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}" + } + return TouchBoundsExpansion( + packedValue = TouchBoundsExpansion.pack(start, top, end, bottom, true) + ) +} + +/** + * Creates a [DpTouchBoundsExpansion] that's aware of [LayoutDirection]. See + * [DpTouchBoundsExpansion.start] and [DpTouchBoundsExpansion.end] for more details about + * [LayoutDirection]. + * + * The `start`, `top`, `end` and `bottom` represent the distance that the touch bounds is expanded + * along the corresponding edge. + */ +fun DpTouchBoundsExpansion( + start: Dp = 0.dp, + top: Dp = 0.dp, + end: Dp = 0.dp, + bottom: Dp = 0.dp +): DpTouchBoundsExpansion { + return DpTouchBoundsExpansion(start, top, end, bottom, true) +} diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/WeakReference.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/WeakReference.kt index 8875e14c7b7c6..f348afca89bbf 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/WeakReference.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/WeakReference.kt @@ -15,8 +15,7 @@ */ package androidx.compose.ui.node -// TODO mark internal once https://youtrack.jetbrains.com/issue/KT-36695 is fixed -expect class WeakReference(referent: T) { +internal expect class WeakReference(referent: T) { fun clear() fun get(): T? diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt index d8f78e6f140d6..ed69d8dbefd91 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree import androidx.compose.ui.draw.DrawModifier import androidx.compose.ui.focus.FocusManager @@ -65,6 +66,14 @@ val LocalAutofill = staticCompositionLocalOf { null } val LocalAutofillTree = staticCompositionLocalOf { noLocalProvidedFor("LocalAutofillTree") } +/** + * The CompositionLocal that can be used to trigger autofill actions. Eg. + * [LocalAutofillManager.commit]. + */ +@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") +val LocalAutofillManager = + staticCompositionLocalOf { noLocalProvidedFor("LocalAutofillManager") } + /** The CompositionLocal to provide communication with platform clipboard service. */ val LocalClipboardManager = staticCompositionLocalOf { noLocalProvidedFor("LocalClipboardManager") } @@ -193,6 +202,7 @@ internal fun ProvideCommonCompositionLocals( CompositionLocalProvider( LocalAccessibilityManager provides owner.accessibilityManager, LocalAutofill provides owner.autofill, + LocalAutofillManager provides owner.autofillManager, LocalAutofillTree provides owner.autofillTree, LocalClipboardManager provides owner.clipboardManager, LocalDensity provides owner.density, diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Synchronization.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Synchronization.kt index 875155e4360ea..5ea5ba0879c14 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Synchronization.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Synchronization.kt @@ -16,12 +16,13 @@ package androidx.compose.ui.platform -// TODO: Replace with another copy for expect/actual posix implementation +internal expect class SynchronizedObject -internal class SynchronizedObject : kotlinx.atomicfu.locks.SynchronizedObject() - -internal fun createSynchronizedObject() = SynchronizedObject() +/** + * Returns [ref] as a [SynchronizedObject] on platforms where [Any] is a valid [SynchronizedObject], + * or a new [SynchronizedObject] instance if [ref] is null or this is not supported on the current + * platform. + */ +internal expect inline fun makeSynchronizedObject(ref: Any? = null): SynchronizedObject -@PublishedApi -internal inline fun synchronized(lock: SynchronizedObject, block: () -> R): R = - kotlinx.atomicfu.locks.synchronized(lock, block) +internal expect inline fun synchronized(lock: SynchronizedObject, block: () -> R): R diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TextToolbar.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TextToolbar.kt index e28ade88684c7..1d0292a29ee94 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TextToolbar.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TextToolbar.kt @@ -28,9 +28,44 @@ interface TextToolbar { * * @param rect region of interest. The selected region around which the floating toolbar should * show. This rect is in global coordinates system. - * @param onCopyRequested callback to copy text into ClipBoardManager. - * @param onPasteRequested callback to get text from ClipBoardManager and paste it. - * @param onCutRequested callback to cut text and copy the text into ClipBoardManager. + * @param onCopyRequested callback to copy text into ClipBoardManager. If null, the copy option + * will not be shown. + * @param onPasteRequested callback to get text from ClipBoardManager and paste it. If null, the + * paste option will not be shown. + * @param onCutRequested callback to cut text and copy the text into ClipBoardManager. If null, + * the cut option will not be shown. + * @param onAutofillRequested callback to autofill the field. If null, the autofill option will + * not be shown. + */ + fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)? = null, + onPasteRequested: (() -> Unit)? = null, + onCutRequested: (() -> Unit)? = null, + onSelectAllRequested: (() -> Unit)? = null, + onAutofillRequested: (() -> Unit)? = null + ) { + showMenu( + rect = rect, + onCopyRequested = onCopyRequested, + onPasteRequested = onPasteRequested, + onCutRequested = onCutRequested, + onSelectAllRequested = onSelectAllRequested + ) + } + + /** + * Show the floating toolbar(post-M) or primary toolbar(pre-M) for copying, cutting and pasting + * text. + * + * @param rect region of interest. The selected region around which the floating toolbar should + * show. This rect is in global coordinates system. + * @param onCopyRequested callback to copy text into ClipBoardManager. If null, the copy option + * will not be shown. + * @param onPasteRequested callback to get text from ClipBoardManager and paste it. If null, the + * paste option will not be shown. + * @param onCutRequested callback to cut text and copy the text into ClipBoardManager. If null, + * the cut option will not be shown. */ fun showMenu( rect: Rect, diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/WindowInfo.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/WindowInfo.kt index c4a677f76b163..2571b087f9bb3 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/WindowInfo.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/WindowInfo.kt @@ -19,15 +19,18 @@ package androidx.compose.ui.platform import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.input.pointer.EmptyPointerKeyboardModifiers import androidx.compose.ui.input.pointer.PointerKeyboardModifiers -import androidx.compose.ui.internal.JvmDefaultWithCompatibility +import androidx.compose.ui.unit.IntSize /** Provides information about the Window that is hosting this compose hierarchy. */ @Stable -@JvmDefaultWithCompatibility -expect interface WindowInfo { +interface WindowInfo { /** * Indicates whether the window hosting this compose hierarchy is in focus. * @@ -39,7 +42,17 @@ expect interface WindowInfo { /** Indicates the state of keyboard modifiers (pressed or not). */ @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") - open val keyboardModifiers: PointerKeyboardModifiers + val keyboardModifiers: PointerKeyboardModifiers + get() = WindowInfoImpl.GlobalKeyboardModifiers.value + + /** + * Size of the window. This size excludes insets, such as any system bars, so it is not safe to + * assume that this size matches the available space of the compose hierarchy hosted inside this + * window. Instead this size should be used as a breakpoint when changing between UI + * configurations, or similar window-dependent configuration. + */ + val containerSize: IntSize + get() = IntSize(Int.MIN_VALUE, Int.MIN_VALUE) } @Composable @@ -50,3 +63,27 @@ internal fun WindowFocusObserver(onWindowFocusChanged: (isWindowFocused: Boolean snapshotFlow { windowInfo.isWindowFocused }.collect { callback.value(it) } } } + +internal class WindowInfoImpl : WindowInfo { + private val _containerSize = mutableStateOf(IntSize.Zero) + + override var isWindowFocused: Boolean by mutableStateOf(false) + + override var keyboardModifiers: PointerKeyboardModifiers + get() = GlobalKeyboardModifiers.value + set(value) { + GlobalKeyboardModifiers.value = value + } + + override var containerSize: IntSize + get() = _containerSize.value + set(value) { + _containerSize.value = value + } + + companion object { + // One instance across all windows makes sense, since the state of KeyboardModifiers is + // common for all windows. + internal val GlobalKeyboardModifiers = mutableStateOf(EmptyPointerKeyboardModifiers()) + } +} diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt new file mode 100644 index 0000000000000..1f2cc5d015fc0 --- /dev/null +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt @@ -0,0 +1,85 @@ +/* + * 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. + */ + +package androidx.compose.ui.semantics + +import androidx.compose.runtime.collection.MutableVector +import androidx.compose.ui.layout.LayoutInfo +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.node.LayoutNode + +/** + * This is an internal interface that can be used by [SemanticsListener]s to read semantic + * information from layout nodes. The root [SemanticsInfo] can be accessed using + * [SemanticsOwner.rootInfo], and particular [SemanticsInfo] can be looked up by their [semanticsId] + * by using [SemanticsOwner.get]. + */ +internal interface SemanticsInfo : LayoutInfo { + /** The semantics configuration (Semantic properties and actions) associated with this node. */ + val semanticsConfiguration: SemanticsConfiguration? + + /** + * The [SemanticsInfo] of the parent. + * + * This includes parents that do not have any semantics modifiers. + */ + override val parentInfo: SemanticsInfo? + + /** + * Returns the children list sorted by their [LayoutNode.zIndex] first (smaller first) and the + * order they were placed via [Placeable.placeAt] by parent (smaller first). Please note that + * this list contains not placed items as well, so you have to manually filter them. + * + * Note that the object is reused so you shouldn't save it for later. + */ + val childrenInfo: MutableVector +} + +/** The semantics parent (nearest ancestor which has semantic properties). */ +internal fun SemanticsInfo.findSemanticsParent(): SemanticsInfo? { + var parent = parentInfo + while (parent != null) { + if (parent.semanticsConfiguration != null) return parent + parent = parent.parentInfo + } + return null +} + +/** The nearest semantics ancestor that is merging descendants. */ +internal fun SemanticsInfo.findMergingSemanticsParent(): SemanticsInfo? { + var parent = parentInfo + while (parent != null) { + if (parent.semanticsConfiguration?.isMergingSemanticsOfDescendants == true) return parent + parent = parent.parentInfo + } + return null +} + +internal inline fun SemanticsInfo.findSemanticsChildren( + includeDeactivated: Boolean = false, + block: (SemanticsInfo) -> Unit +) { + val unvisitedStack = MutableVector(childrenInfo.size) + childrenInfo.forEachReversed { unvisitedStack += it } + while (unvisitedStack.isNotEmpty()) { + val child = unvisitedStack.removeAt(unvisitedStack.lastIndex) + when { + child.isDeactivated && !includeDeactivated -> continue + child.semanticsConfiguration != null -> block(child) + else -> child.childrenInfo.forEachReversed { unvisitedStack += it } + } + } +} diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt new file mode 100644 index 0000000000000..b51d7c8fe9281 --- /dev/null +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt @@ -0,0 +1,34 @@ +/* + * 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. + */ + +package androidx.compose.ui.semantics + +/** A listener that can be used to observe semantic changes. */ +internal interface SemanticsListener { + + /** + * [onSemanticsChanged] is called when the [SemanticsConfiguration] of a LayoutNode changes, or + * when a node calls SemanticsModifierNode.invalidateSemantics. + * + * @param semanticsInfo the current [SemanticsInfo] of the layout node that has changed. + * @param previousSemanticsConfiguration the previous [SemanticsConfiguration] associated with + * the layout node. + */ + fun onSemanticsChanged( + semanticsInfo: SemanticsInfo, + previousSemanticsConfiguration: SemanticsConfiguration? + ) +} diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt index 35478d68ed15a..84ae0fc2f7f11 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt @@ -45,7 +45,7 @@ internal fun SemanticsNode(layoutNode: LayoutNode, mergingEnabled: Boolean) = layoutNode.nodes.head(Nodes.Semantics)!!.node, mergingEnabled, layoutNode, - layoutNode.collapsedSemantics!! + layoutNode.semanticsConfiguration ?: SemanticsConfiguration() ) internal fun SemanticsNode( @@ -70,7 +70,7 @@ internal fun SemanticsNode( outerSemanticsNode.node, mergingEnabled, layoutNode, - layoutNode.collapsedSemantics ?: SemanticsConfiguration() + layoutNode.semanticsConfiguration ?: SemanticsConfiguration() ) /** @@ -99,7 +99,7 @@ internal constructor( !isFake && replacedChildren.isEmpty() && layoutNode.findClosestParentNode { - it.collapsedSemantics?.isMergingSemanticsOfDescendants == true + it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true } == null /** The [LayoutInfo] that this is associated with. */ @@ -345,7 +345,7 @@ internal constructor( if (mergingEnabled) { node = this.layoutNode.findClosestParentNode { - it.collapsedSemantics?.isMergingSemanticsOfDescendants == true + it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true } } @@ -474,7 +474,9 @@ internal val LayoutNode.outerMergingSemantics: SemanticsModifierNode? * Executes [selector] on every parent of this [LayoutNode] and returns the closest [LayoutNode] to * return `true` from [selector] or null if [selector] returns false for all ancestors. */ -internal fun LayoutNode.findClosestParentNode(selector: (LayoutNode) -> Boolean): LayoutNode? { +internal inline fun LayoutNode.findClosestParentNode( + selector: (LayoutNode) -> Boolean +): LayoutNode? { var currentParent = this.parent while (currentParent != null) { if (selector(currentParent)) { diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt index dffed0f32e525..b98715556ec3b 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.semantics +import androidx.collection.IntObjectMap +import androidx.collection.MutableObjectList import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.util.fastForEach @@ -23,7 +25,8 @@ import androidx.compose.ui.util.fastForEach class SemanticsOwner internal constructor( private val rootNode: LayoutNode, - private val outerSemanticsNode: EmptySemanticsModifier + private val outerSemanticsNode: EmptySemanticsModifier, + private val nodes: IntObjectMap ) { /** * The root node of the semantics tree. Does not contain any unmerged data. May contain merged @@ -47,6 +50,22 @@ internal constructor( unmergedConfig = SemanticsConfiguration() ) } + + internal val listeners = MutableObjectList(2) + + internal val rootInfo: SemanticsInfo + get() = rootNode + + internal operator fun get(semanticsId: Int): SemanticsInfo? { + return nodes[semanticsId] + } + + internal fun notifySemanticsChange( + semanticsInfo: SemanticsInfo, + previousSemanticsConfiguration: SemanticsConfiguration? + ) { + listeners.forEach { it.onSemanticsChanged(semanticsInfo, previousSemanticsConfiguration) } + } } /** @@ -70,6 +89,7 @@ fun SemanticsOwner.getAllSemanticsNodes( .toList() } +@Suppress("unused") @Deprecated(message = "Use a new overload instead", level = DeprecationLevel.HIDDEN) fun SemanticsOwner.getAllSemanticsNodes(mergingEnabled: Boolean) = getAllSemanticsNodes(mergingEnabled, true) diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt index b549fe7f60273..7774ff613d86b 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt @@ -117,8 +117,7 @@ object SemanticsProperties { ) /** @see SemanticsPropertyReceiver.contentType */ - // TODO(b/333102566): make these semantics properties public when Autofill is ready to go live - internal val ContentType = + val ContentType = SemanticsPropertyKey( name = "ContentType", mergePolicy = { parentValue, _ -> @@ -128,8 +127,7 @@ object SemanticsProperties { ) /** @see SemanticsPropertyReceiver.contentDataType */ - // TODO(b/333102566): make these semantics properties public when Autofill is ready to go live - internal val ContentDataType = + val ContentDataType = SemanticsPropertyKey( name = "ContentDataType", mergePolicy = { parentValue, _ -> @@ -287,8 +285,7 @@ object SemanticsActions { val ScrollToIndex = ActionPropertyKey<(Int) -> Boolean>("ScrollToIndex") /** @see SemanticsPropertyReceiver.onAutofillText */ - // TODO(b/333102566): make this action public when Autofill is ready to go live - internal val OnAutofillText = ActionPropertyKey<(AnnotatedString) -> Boolean>("OnAutofillText") + val OnAutofillText = ActionPropertyKey<(AnnotatedString) -> Boolean>("OnAutofillText") /** @see SemanticsPropertyReceiver.setProgress */ val SetProgress = ActionPropertyKey<(progress: Float) -> Boolean>("SetProgress") @@ -904,8 +901,7 @@ fun SemanticsPropertyReceiver.hideFromAccessibility() { * * @see SemanticsProperties.ContentType */ -// TODO(b/333102566): make these semantics properties public when Autofill is ready to go live -internal var SemanticsPropertyReceiver.contentType by SemanticsProperties.ContentType +var SemanticsPropertyReceiver.contentType by SemanticsProperties.ContentType /** * Content data type information. @@ -915,8 +911,7 @@ internal var SemanticsPropertyReceiver.contentType by SemanticsProperties.Conten * * @see SemanticsProperties.ContentType */ -// TODO(b/333102566): make these semantics properties public when Autofill is ready to go live -internal var SemanticsPropertyReceiver.contentDataType by SemanticsProperties.ContentDataType +var SemanticsPropertyReceiver.contentDataType by SemanticsProperties.ContentDataType /** * A value to manually control screenreader traversal order. @@ -1188,8 +1183,7 @@ fun SemanticsPropertyReceiver.scrollToIndex(label: String? = null, action: (Int) * @param label Optional label for this action. * @param action Action to be performed when the [SemanticsActions.OnAutofillText] is called. */ -// TODO(b/333102566): make this action public when Autofill is ready to go live -internal fun SemanticsPropertyReceiver.onAutofillText( +fun SemanticsPropertyReceiver.onAutofillText( label: String? = null, action: ((AnnotatedString) -> Boolean)? ) { diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectInfo.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectInfo.kt new file mode 100644 index 0000000000000..3537a26a59cd9 --- /dev/null +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectInfo.kt @@ -0,0 +1,135 @@ +/* + * 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. + */ + +package androidx.compose.ui.spatial + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.roundToIntRect + +/** + * Represents an axis-aligned bounding Rectangle for an element in a compose hierarchy, in the + * coordinates of either the Root of the compose hierarchy, the Window, or the Screen. + * + * @see androidx.compose.ui.layout.onRectChanged + */ +class RectInfo +internal constructor( + private val topLeft: Long, + private val bottomRight: Long, + private val windowOffset: IntOffset, + private val screenOffset: IntOffset, + private val viewToWindowMatrix: Matrix?, +) { + /** + * The top left position of the Rect in the coordinates of the root node of the compose + * hierarchy. + */ + val positionInRoot: IntOffset + get() = IntOffset(topLeft) + + /** The top left position of the Rect in the coordinates of the Window it is contained in */ + val positionInWindow: IntOffset + get() { + val x = screenOffset.x - windowOffset.x + val y = screenOffset.y - windowOffset.y + val l = unpackX(topLeft) + val t = unpackY(topLeft) + return IntOffset(l + x, t + y) + } + + /** The top left position of the Rect in the coordinates of the Screen it is contained in. */ + val positionInScreen: IntOffset + get() { + val x = screenOffset.x + val y = screenOffset.y + val l = unpackX(topLeft) + val t = unpackY(topLeft) + return IntOffset(l + x, t + y) + } + + /** The width, in pixels, of the Rect */ + val width: Int + get() { + val l = unpackX(topLeft) + val r = unpackX(bottomRight) + return r - l + } + + /** The height, in pixels, of the Rect */ + val height: Int + get() { + val t = unpackY(topLeft) + val b = unpackY(bottomRight) + return b - t + } + + /** + * The positioned bounding Rect in the coordinates of the root node of the compose hierarchy. + */ + val rootRect: IntRect + get() { + val l = unpackX(topLeft) + val t = unpackY(topLeft) + val r = unpackX(bottomRight) + val b = unpackY(bottomRight) + return IntRect(l, t, r, b) + } + + /** The positioned bounding Rect in the coordinates of the Window which it is contained in. */ + val windowRect: IntRect + get() { + val l = unpackX(topLeft) + val t = unpackY(topLeft) + val r = unpackX(bottomRight) + val b = unpackY(bottomRight) + if (viewToWindowMatrix != null) { + // TODO: we could implement a `Matrix.map(l, t, r, b): IntRect` that was only a + // single allocation if we wanted to. this would avoid the two Rect(FFFF) + // allocations that we have here. + return viewToWindowMatrix + .map(Rect(l.toFloat(), t.toFloat(), r.toFloat(), b.toFloat())) + .roundToIntRect() + } + val x = screenOffset.x - windowOffset.x + val y = screenOffset.y - windowOffset.y + return IntRect(l + x, t + y, r + x, b + y) + } + + /** The positioned bounding Rect in the coordinates of the Screen which it is contained in. */ + val screenRect: IntRect + get() { + if (viewToWindowMatrix != null) { + val windowRect = windowRect + val offset = windowOffset + return IntRect( + windowRect.left + offset.x, + windowRect.top + offset.y, + windowRect.right + offset.x, + windowRect.bottom + offset.y, + ) + } + val l = unpackX(topLeft) + val t = unpackY(topLeft) + val r = unpackX(bottomRight) + val b = unpackY(bottomRight) + val x = screenOffset.x + val y = screenOffset.y + return IntRect(l + x, t + y, r + x, b + y) + } +} diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt index 1105c4f1963c5..a90b152a6a996 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectList.kt @@ -57,11 +57,12 @@ internal class RectList { * Long 3 (64 bits): the "meta" long * 26 bits: item id * 26 bits: parent id - * 10 bits: last child offset + * 9 bits: last child offset + * 1 bits: updated * 1 bits: focusable * 1 bits: gesturable */ - @JvmField internal var items: LongArray = LongArray(LongsPerItem * 64) // 64 items + @JvmField internal var items: LongArray = LongArray(LongsPerItem * InitialSize) /** * We allocate a 2nd LongArray. This is always going to be sized identical to [items], and @@ -76,7 +77,7 @@ internal class RectList { * @see [defragment] * @see [updateSubhierarchy] */ - @JvmField internal var stack: LongArray = LongArray(LongsPerItem * 64) // 64 items + @JvmField internal var stack: LongArray = LongArray(LongsPerItem * InitialSize) /** * The size of the items array that is filled with actual data. This is different from @@ -145,13 +146,28 @@ internal class RectList { items[index + 0] = packXY(l, t) items[index + 1] = packXY(r, b) - items[index + 2] = packMeta(value, parentId, lastChildOffset = 0, focusable, gesturable) + items[index + 2] = + packMeta( + value, + parentId, + lastChildOffset = 0, + // TODO: consider the fact that we will be updating every rect on insert, and that + // will probably impact insert times somewhat negatively. We could potentially + // try and check whether or not a node has a "global rect listener" on it before + // insert, or alternatively "mark" the updated array when we add a listener so + // that we could avoid the "fire" for every rect in the collection. This might not + // be a big deal though so let's wait until we can measure and find out if it is + // a problem + updated = true, + focusable, + gesturable + ) if (parentId < 0) return val parentId = parentId and Lower26Bits // After inserting, find the item with id = parentId and update it's "last child offset". var i = index - LongsPerItem - while (i > 0) { + while (i >= 0) { val meta = items[i + 2] if (unpackMetaValue(meta) == parentId) { // TODO: right now this number will always be a multiple of 3. Since the last child @@ -214,6 +230,7 @@ internal class RectList { if (unpackMetaValue(meta) == value) { items[i + 0] = packXY(l, t) items[i + 1] = packXY(r, b) + items[i + 2] = metaMarkUpdated(meta) return true } i += LongsPerItem @@ -240,6 +257,7 @@ internal class RectList { val prevLT = items[i + 0] items[i + 0] = packXY(l, t) items[i + 1] = packXY(r, b) + items[i + 2] = metaMarkUpdated(meta) val deltaX = l - unpackX(prevLT) val deltaY = t - unpackY(prevLT) if ((deltaX != 0) or (deltaY != 0)) { @@ -259,7 +277,8 @@ internal class RectList { packMeta( itemId = id, parentId = 0, - lastChildOffset = items.size, + lastChildOffset = itemsSize, + updated = false, focusable = false, gesturable = false, ), @@ -297,7 +316,7 @@ internal class RectList { val parentId = unpackMetaValue(idAndStartAndOffset) // parent id is in the id slot var i = unpackMetaParentId(idAndStartAndOffset) // start index is in the parent id slot val offset = unpackMetaLastChildOffset(idAndStartAndOffset) - val endIndex = if (offset == Lower10Bits) size else offset + i + val endIndex = if (offset == Lower9Bits) size else offset + i if (i < 0) break while (i < items.size - 2) { if (i >= endIndex) break @@ -308,6 +327,7 @@ internal class RectList { items[i + 0] = packXY(unpackX(topLeft) + deltaX, unpackY(topLeft) + deltaY) items[i + 1] = packXY(unpackX(bottomRight) + deltaX, unpackY(bottomRight) + deltaY) + items[i + 2] = metaMarkUpdated(meta) if (unpackMetaLastChildOffset(meta) > 0) { // we need to store itemId, lastChildOffset, and a "start index". // For convenience, we just use `meta` which already encodes two of those @@ -320,6 +340,22 @@ internal class RectList { } } + fun markUpdated(value: Int) { + val value = value and Lower26Bits + val items = items + val size = itemsSize + var i = 0 + while (i < items.size - 2) { + if (i >= size) break + val meta = items[i + 2] + if (unpackMetaValue(meta) == value) { + items[i + 2] = metaMarkUpdated(meta) + return + } + i += LongsPerItem + } + } + fun withRect(value: Int, block: (Int, Int, Int, Int) -> Unit): Boolean { val value = value and Lower26Bits val items = items @@ -612,6 +648,37 @@ internal class RectList { stack = from } + fun clearUpdated() { + val items = items + val size = itemsSize + var i = 0 + while (i < items.size - 2) { + if (i >= size) break + items[i + 2] = metaUnMarkUpdated(items[i + 2]) + i += LongsPerItem + } + } + + inline fun forEachUpdatedRect(block: (Int, Long, Long) -> Unit) { + val items = items + val size = itemsSize + var i = 0 + while (i < items.size - 2) { + if (i >= size) break + val meta = items[i + 2] + if (unpackMetaUpdated(meta) != 0) { + val topLeft = items[i + 0] + val bottomRight = items[i + 1] + block( + unpackMetaValue(meta), + topLeft, + bottomRight, + ) + } + i += LongsPerItem + } + } + fun debugString(): String = buildString { val items = items val size = itemsSize @@ -634,10 +701,11 @@ internal class RectList { } internal const val LongsPerItem = 3 +internal const val InitialSize = 64 internal const val Lower26Bits = 0b0000_0011_1111_1111_1111_1111_1111_1111 -internal const val Lower10Bits = 0b0000_0000_0000_0000_0000_0011_1111_1111 +internal const val Lower9Bits = 0b0000_0000_0000_0000_0000_0001_1111_1111 internal const val EverythingButParentId = 0xfff0_0000_03ff_ffffUL -internal const val EverythingButLastChildOffset = 0xc00f_ffff_ffff_ffffUL +internal const val EverythingButLastChildOffset = 0xe00fffffffffffffUL /** * This is the "meta" value that we assign to every removed value. @@ -645,7 +713,7 @@ internal const val EverythingButLastChildOffset = 0xc00f_ffff_ffff_ffffUL * @see RectList.remove * @see packMeta */ -internal const val TombStone = 0x3fff_ffff_ffff_ffffL // packMeta(-1, -1, -1, false, false) +internal const val TombStone = 0x1fff_ffff_ffff_ffffL // packMeta(-1, -1, -1, false, false, false) internal const val AxisNorth: Int = 0 internal const val AxisSouth: Int = 1 @@ -658,17 +726,20 @@ internal inline fun packMeta( itemId: Int, parentId: Int, lastChildOffset: Int, + updated: Boolean, focusable: Boolean, gesturable: Boolean, ): Long = // 26 bits: item id // 26 bits: parent id - // 10 bits: last child offset + // 9 bits: last child offset + // 1 bits: updated // 1 bits: focusable // 1 bits: gesturable (gesturable.toLong() shl 63) or (focusable.toLong() shl 62) or - ((lastChildOffset and Lower10Bits).toLong() shl 52) or + (updated.toLong() shl 61) or + ((lastChildOffset and Lower9Bits).toLong() shl 52) or ((parentId and Lower26Bits).toLong() shl 26) or ((itemId and Lower26Bits).toLong() shl 0) @@ -677,19 +748,28 @@ internal inline fun unpackMetaValue(meta: Long): Int = meta.toInt() and Lower26B internal inline fun unpackMetaParentId(meta: Long): Int = (meta shr 26).toInt() and Lower26Bits internal inline fun unpackMetaLastChildOffset(meta: Long): Int = - (meta shr 52).toInt() and Lower10Bits + (meta shr 52).toInt() and Lower9Bits internal inline fun metaWithParentId(meta: Long, parentId: Int): Long = (meta and EverythingButParentId.toLong()) or ((parentId and Lower26Bits).toLong() shl 26) +internal inline fun metaWithUpdated(meta: Long, updated: Boolean): Long = + (meta and (0b1L shl 61).inv()) or (updated.toLong() shl 61) + +internal inline fun metaMarkUpdated(meta: Long): Long = meta or (1L shl 61) + +internal inline fun metaUnMarkUpdated(meta: Long): Long = meta and (1L shl 61).inv() + internal inline fun metaWithLastChildOffset(meta: Long, lastChildOffset: Int): Long = (meta and EverythingButLastChildOffset.toLong()) or - ((lastChildOffset and Lower10Bits).toLong() shl 52) + ((lastChildOffset and Lower9Bits).toLong() shl 52) internal inline fun unpackMetaFocusable(meta: Long): Int = (meta shr 62).toInt() and 0b1 internal inline fun unpackMetaGesturable(meta: Long): Int = (meta shr 63).toInt() and 0b1 +internal inline fun unpackMetaUpdated(meta: Long): Int = (meta shr 61).toInt() and 0b1 + internal inline fun unpackX(xy: Long): Int = (xy shr 32).toInt() internal inline fun unpackY(xy: Long): Int = (xy).toInt() diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt index 63751cec0d741..894da0ce49026 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt @@ -19,44 +19,127 @@ package androidx.compose.ui.spatial import androidx.collection.mutableObjectListOf import androidx.compose.ui.ComposeUiFlags import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.currentTimeMillis import androidx.compose.ui.geometry.MutableRect import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.isIdentity +import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.node.NodeCoordinator +import androidx.compose.ui.postDelayed +import androidx.compose.ui.removePost import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.plus import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.util.trace +import kotlin.math.max +import kotlinx.coroutines.DisposableHandle internal class RectManager { val rects: RectList = RectList() + private val throttledCallbacks = ThrottledCallbacks() private val callbacks = mutableObjectListOf<() -> Unit>() private var isDirty = false + private var isScreenOrWindowDirty = false private var isFragmented = false + private var dispatchToken: Any? = null + private var scheduledDispatchDeadline: Long = -1 + private val dispatchLambda = { + dispatchToken = null + trace("OnPositionedDispatch") { dispatchCallbacks() } + } fun invalidate() { isDirty = true } + fun updateOffsets( + screenOffset: IntOffset, + windowOffset: IntOffset, + viewToWindowMatrix: Matrix + ) { + val analysis = viewToWindowMatrix.analyzeComponents() + isScreenOrWindowDirty = + throttledCallbacks.updateOffsets( + screenOffset, + windowOffset, + if (analysis.hasNonTranslationComponents) viewToWindowMatrix else null, + ) || isScreenOrWindowDirty + } + // TODO: we need to make sure these are dispatched after draw if needed fun dispatchCallbacks() { + val currentTime = currentTimeMillis() + // TODO: we need to move this to avoid double-firing if (isDirty) { isDirty = false - // The hierarchy is "settled" in terms of nodes being added/removed for this frame - // This makes it a reasonable time to "defragment" the RectList data structure. This - // will keep operations on this data structure efficient over time. This is a fairly - // cheap operation to run, so we just do it every time - if (isFragmented) { - isFragmented = false - rects.defragment() - } callbacks.forEach { it() } + rects.forEachUpdatedRect { id, topLeft, bottomRight -> + throttledCallbacks.fire(id, topLeft, bottomRight, currentTime) + } + rects.clearUpdated() + } + if (isScreenOrWindowDirty) { + isScreenOrWindowDirty = false + throttledCallbacks.fireAll(currentTime) + } + // The hierarchy is "settled" in terms of nodes being added/removed for this frame + // This makes it a reasonable time to "defragment" the RectList data structure. This + // will keep operations on this data structure efficient over time. This is a fairly + // cheap operation to run, so we just do it every time + if (isFragmented) { + isFragmented = false + // TODO: if we want to take advantage of the "generation", we will be motivated to + // call this less often. Alternatively we could track the number of remove() calls + // we have made and only call defragment once it exceeds a certain percentage of + // the overall list. + rects.defragment() + } + // this gets called frequently, but we might need to schedule it more often to ensure that + // debounced callbacks get fired + throttledCallbacks.triggerDebounced(currentTime) + } + + fun scheduleDebounceCallback(ensureSomethingScheduled: Boolean) { + val canExitEarly = !ensureSomethingScheduled || dispatchToken != null + val nextDeadline = throttledCallbacks.minDebounceDeadline + if (nextDeadline < 0 && canExitEarly) { + return } + val currentScheduledDeadline = scheduledDispatchDeadline + if (currentScheduledDeadline == nextDeadline && canExitEarly) { + return + } + if (dispatchToken != null) { + removePost(dispatchToken) + } + val currentTime = currentTimeMillis() + val nextFrameIsh = currentTime + 16 + val deadline = max(nextDeadline, nextFrameIsh) + scheduledDispatchDeadline = deadline + val delay = deadline - currentTime + dispatchToken = postDelayed(delay, dispatchLambda) + } + + fun currentRectInfo(id: Int, node: DelegatableNode): RectInfo? { + var result: RectInfo? = null + rects.withRect(id) { l, t, r, b -> + result = + rectInfoFor( + node, + packXY(l, t), + packXY(r, b), + throttledCallbacks.windowOffset, + throttledCallbacks.screenOffset, + throttledCallbacks.viewToWindowMatrix, + ) + } + return result } fun registerOnChangedCallback(callback: () -> Unit): Any? { @@ -64,23 +147,47 @@ internal class RectManager { return callback } + fun registerOnRectChangedCallback( + id: Int, + throttleMs: Int, + debounceMs: Int, + node: DelegatableNode, + callback: (RectInfo) -> Unit + ): DisposableHandle { + return throttledCallbacks.register( + id, + throttleMs.toLong(), + debounceMs.toLong(), + node, + callback, + ) + } + fun unregisterOnChangedCallback(token: Any?) { @Suppress("UNCHECKED_CAST") token as? (() -> Unit) ?: return callbacks.remove(token) } + fun invalidateCallbacksFor(layoutNode: LayoutNode) { + isDirty = true + rects.markUpdated(layoutNode.semanticsId) + scheduleDebounceCallback(ensureSomethingScheduled = true) + } + fun onLayoutLayerPositionalPropertiesChanged(layoutNode: LayoutNode) { @OptIn(ExperimentalComposeUiApi::class) if (!ComposeUiFlags.isRectTrackingEnabled) return val outerToInnerOffset = layoutNode.outerToInnerOffset() if (outerToInnerOffset.isSet) { - // translational properties only. AARB still valid. + // translational properties only. AABB still valid. layoutNode.outerToInnerOffset = outerToInnerOffset layoutNode.outerToInnerOffsetDirty = false layoutNode.forEachChild { // NOTE: this calls rectlist.move(...) so does not need to be recursive + // TODO: we could potentially move to a single call of `updateSubhierarchy(...)` onLayoutPositionChanged(it, it.outerCoordinator.position, false) } + invalidateCallbacksFor(layoutNode) } else { // there are rotations/skews/scales going on, so we need to do a more expensive update insertOrUpdateTransformedNodeSubhierarchy(layoutNode) diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt new file mode 100644 index 0000000000000..fdc24a452023d --- /dev/null +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt @@ -0,0 +1,309 @@ +/* + * 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. + */ + +package androidx.compose.ui.spatial + +import androidx.collection.MutableIntObjectMap +import androidx.collection.mutableIntObjectMapOf +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.Nodes +import androidx.compose.ui.node.requireCoordinator +import androidx.compose.ui.node.requireLayoutNode +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.round +import kotlin.math.min +import kotlinx.coroutines.DisposableHandle + +internal class ThrottledCallbacks { + + inner class Entry( + val id: Int, + val throttleMillis: Long, + val debounceMillis: Long, + val node: DelegatableNode, + val callback: (RectInfo) -> Unit, + ) : DisposableHandle { + + var next: Entry? = null + + var topLeft: Long = 0 + var bottomRight: Long = 0 + var lastInvokeMillis: Long = -throttleMillis + var lastUninvokedFireMillis: Long = -1 + + override fun dispose() { + map.multiRemove(id, this) + } + + fun fire( + topLeft: Long, + bottomRight: Long, + windowOffset: IntOffset, + screenOffset: IntOffset, + viewToWindowMatrix: Matrix?, + ) { + val rect = + rectInfoFor( + node, + topLeft, + bottomRight, + windowOffset, + screenOffset, + viewToWindowMatrix + ) + if (rect != null) { + callback(rect) + } + } + } + + val map = mutableIntObjectMapOf() + + // We can use this to schedule a "triggerDebounced" call. If it is -1, then nothing + // needs to be scheduled. + var minDebounceDeadline: Long = -1 + var windowOffset: IntOffset = IntOffset.Zero + var screenOffset: IntOffset = IntOffset.Zero + var viewToWindowMatrix: Matrix? = null + + fun updateOffsets(screen: IntOffset, window: IntOffset, matrix: Matrix?): Boolean { + var updated = false + if (window != windowOffset) { + windowOffset = window + updated = true + } + if (screen != screenOffset) { + screenOffset = screen + updated = true + } + if (matrix != null) { + viewToWindowMatrix = matrix + updated = true + } + return updated + } + + private fun roundDownToMultipleOf8(x: Long): Long { + return (x shr 3) shl 3 + } + + fun register( + id: Int, + throttleMs: Long, + debounceMs: Long, + node: DelegatableNode, + callback: (RectInfo) -> Unit, + ): DisposableHandle { + // If zero is set for debounce, we use throttle in its place. This guarantees that + // consumers will get the value where the node "settled". + val debounceToUse = if (debounceMs == 0L) throttleMs else debounceMs + + return map.multiPut(id, Entry(id, throttleMs, debounceToUse, node, callback)) + } + + // We call this when a layout node with `semanticsId = id` changes it's global bounds. For + // throttled callbacks this may cause the callback to get invoked, for debounced nodes it + // updates the deadlines + fun fire(id: Int, topLeft: Long, bottomRight: Long, currentMillis: Long) { + map.runFor(id) { entry -> + val lastInvokeMillis = entry.lastInvokeMillis + val throttleMillis = entry.throttleMillis + val debounceMillis = entry.debounceMillis + val pastThrottleDeadline = currentMillis - lastInvokeMillis >= throttleMillis + val zeroDebounce = debounceMillis == 0L + val zeroThrottle = throttleMillis == 0L + + entry.topLeft = topLeft + entry.bottomRight = bottomRight + + // There are essentially 3 different cases that we need to handle here: + + // 1. throttle = 0, debounce = 0 + // -> always invoke immediately + // 2. throttle = 0, debounce > 0 + // -> set deadline to milliseconds from now + // 3. throttle > 0, debounce > 0 + // -> invoke if we haven't invoked for milliseconds, otherwise, set the + // deadline to + + // Note that the `throttle > 0, debounce = 0` case is not possible, since we use the + // throttle value as a debounce value in that case. + + val canInvoke = (!zeroDebounce && !zeroThrottle) || zeroDebounce + + if (pastThrottleDeadline && canInvoke) { + entry.lastUninvokedFireMillis = -1 + entry.lastInvokeMillis = currentMillis + entry.fire(topLeft, bottomRight, windowOffset, screenOffset, viewToWindowMatrix) + } else if (!zeroDebounce) { + entry.lastUninvokedFireMillis = currentMillis + val currentMinDeadline = minDebounceDeadline + val thisDeadline = currentMillis + debounceMillis + if (currentMinDeadline > 0 && thisDeadline < currentMinDeadline) { + minDebounceDeadline = currentMinDeadline + } + } + } + } + + fun fireAll(currentMillis: Long) { + val windowOffset = windowOffset + val screenOffset = screenOffset + val viewToWindowMatrix = viewToWindowMatrix + map.multiForEach { entry -> + val lastInvokeMillis = entry.lastInvokeMillis + val throttleOkay = currentMillis - lastInvokeMillis > entry.throttleMillis + val debounceOkay = entry.debounceMillis == 0L + entry.lastUninvokedFireMillis = currentMillis + if (throttleOkay && debounceOkay) { + entry.lastInvokeMillis = currentMillis + entry.fire( + entry.topLeft, + entry.bottomRight, + windowOffset, + screenOffset, + viewToWindowMatrix + ) + } + if (!debounceOkay) { + val currentMinDeadline = minDebounceDeadline + val thisDeadline = currentMillis + entry.debounceMillis + if (currentMinDeadline > 0 && thisDeadline < currentMinDeadline) { + minDebounceDeadline = currentMinDeadline + } + } + } + } + + // We call this to invoke any debounced callbacks that have passed their deadline. This could + // be done every frame, or on some other interval. This means the precision of the debouncing + // is less, but it would reduce the overhead of all of this scheduling. + fun triggerDebounced(currentMillis: Long) { + if (minDebounceDeadline > currentMillis) return + val windowOffset = windowOffset + val screenOffset = screenOffset + val viewToWindowMatrix = viewToWindowMatrix + var minDeadline = Long.MAX_VALUE + map.multiForEach { + if (it.debounceMillis > 0 && it.lastUninvokedFireMillis > 0) { + if (currentMillis - it.lastUninvokedFireMillis > it.debounceMillis) { + it.lastInvokeMillis = currentMillis + it.lastUninvokedFireMillis = -1 + val topLeft = it.topLeft + val bottomRight = it.bottomRight + it.fire(topLeft, bottomRight, windowOffset, screenOffset, viewToWindowMatrix) + } else { + minDeadline = min(minDeadline, it.lastUninvokedFireMillis + it.debounceMillis) + } + } + } + minDebounceDeadline = if (minDeadline == Long.MAX_VALUE) -1 else minDeadline + } + + private inline fun MutableIntObjectMap.multiForEach(block: (Entry) -> Unit) { + forEachValue { it -> + var entry: Entry? = it + while (entry != null) { + block(entry) + entry = entry.next + } + } + } + + private inline fun MutableIntObjectMap.runFor(id: Int, block: (Entry) -> Unit) { + var entry: Entry? = get(id) + while (entry != null) { + block(entry) + entry = entry.next + } + } + + private fun MutableIntObjectMap.multiPut(key: Int, value: Entry): Entry { + var entry: Entry = getOrPut(key) { value } + if (entry !== value) { + while (entry.next != null) { + entry = entry.next!! + } + entry.next = value + } + return value + } + + private fun MutableIntObjectMap.multiRemove(key: Int, value: Entry): Boolean { + return when (val result = remove(key)) { + null -> false + value -> { + val next = value.next + value.next = null + if (next != null) { + put(key, next) + } + true + } + else -> { + put(key, result) + var entry = result + while (entry != null) { + val next = entry.next ?: return false + if (next === value) { + entry.next = value.next + value.next = null + break + } + entry = entry.next + } + true + } + } + } +} + +internal fun rectInfoFor( + node: DelegatableNode, + topLeft: Long, + bottomRight: Long, + windowOffset: IntOffset, + screenOffset: IntOffset, + viewToWindowMatrix: Matrix?, +): RectInfo? { + val coordinator = node.requireCoordinator(Nodes.Layout) + val layoutNode = node.requireLayoutNode() + if (!layoutNode.isPlaced) return null + // this is the outer-rect of the layout node. we may need to transform this + // rectangle to be accurate up to the modifier node requesting the callback. Most + // of the time this will be the outer-most rectangle, so no transformation will be + // needed, and we should optimize for that fact, but we need to make sure that it + // is accurate. + val needsTransform = layoutNode.outerCoordinator !== coordinator + return if (needsTransform) { + val transformed = layoutNode.outerCoordinator.coordinates.localBoundingBoxOf(coordinator) + RectInfo( + transformed.topLeft.round().packedValue, + transformed.bottomRight.round().packedValue, + windowOffset, + screenOffset, + viewToWindowMatrix, + ) + } else + RectInfo( + topLeft, + bottomRight, + windowOffset, + screenOffset, + viewToWindowMatrix, + ) +} diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.desktop.kt deleted file mode 100644 index 808d8435aa563..0000000000000 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/hapticfeedback/PlatformHapticFeedbackType.desktop.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2021 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.hapticfeedback - -/** - * Desktop implementation for [HapticFeedbackType] - */ -internal actual object PlatformHapticFeedbackType { - actual val LongPress: HapticFeedbackType = HapticFeedbackType(0) - actual val TextHandleMove: HapticFeedbackType = HapticFeedbackType(9) -} \ No newline at end of file diff --git a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt index b27430daaf748..a67761d861f9f 100644 --- a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt +++ b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt @@ -20,6 +20,10 @@ import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.InspectorInfo import kotlin.coroutines.cancellation.CancellationException +internal actual fun classKeyForObject(a: Any): Any { + return a::class +} + // TODO: For non-JVM platforms, you can revive the kotlin-reflect implementation from // https://android-review.googlesource.com/c/platform/frameworks/support/+/2441379 @OptIn(ExperimentalComposeUiApi::class) diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/Actuals.jsWasm.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/Actuals.jsWasm.kt index 8e4cc294b1565..660bd39f6ba38 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/Actuals.jsWasm.kt +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/Actuals.jsWasm.kt @@ -16,4 +16,9 @@ package androidx.compose.ui +internal actual fun currentTimeMillis(): Long { + // TODO https://youtrack.jetbrains.com/issue/CMP-7152/Implement-currentTimeMillis-for-web + return 0 +} + internal actual fun getCurrentThreadId(): Long = 0 diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/node/WeakReference.jsWasm.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/node/WeakReference.jsWasm.kt index 513046c8b921a..edae175eb39b1 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/node/WeakReference.jsWasm.kt +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/node/WeakReference.jsWasm.kt @@ -17,7 +17,7 @@ package androidx.compose.ui.node // TODO: https://youtrack.jetbrains.com/issue/COMPOSE-1286/Properly-implement-WeakReference-on-Web -actual class WeakReference actual constructor(referent: T) { +internal actual class WeakReference actual constructor(referent: T) { private var instance: T? = null actual fun clear() { diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/Actuals.jsWasm.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/DebugUtils.jsWasm.kt similarity index 100% rename from compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/Actuals.jsWasm.kt rename to compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/DebugUtils.jsWasm.kt diff --git a/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/Actual.jvm.kt b/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/Actual.jvm.kt index 4a63f3a01b228..0f40f574bc6bb 100644 --- a/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/Actual.jvm.kt +++ b/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/Actual.jvm.kt @@ -29,6 +29,8 @@ internal actual fun classKeyForObject(a: Any): Any { return a.javaClass } +internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() + // TODO: For non-JVM platforms, you can revive the kotlin-reflect implementation from // https://android-review.googlesource.com/c/platform/frameworks/support/+/2441379 internal actual fun InspectorInfo.tryPopulateReflectively(element: ModifierNodeElement<*>) { diff --git a/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/node/WeakReference.jvm.kt b/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/node/WeakReference.jvm.kt index 1a51873ecd672..4f9d094f17607 100644 --- a/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/node/WeakReference.jvm.kt +++ b/compose/ui/ui/src/jvmMain/kotlin/androidx/compose/ui/node/WeakReference.jvm.kt @@ -15,4 +15,5 @@ */ package androidx.compose.ui.node -internal actual typealias WeakReference = java.lang.ref.WeakReference +internal actual class WeakReference actual constructor(referent: T) : + java.lang.ref.WeakReference(referent) diff --git a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.nativeMain.kt b/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.native.kt similarity index 84% rename from compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.nativeMain.kt rename to compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.native.kt index 4788c3525af07..1f655fcb424da 100644 --- a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.nativeMain.kt +++ b/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.native.kt @@ -16,16 +16,21 @@ package androidx.compose.ui +import kotlin.system.getTimeMillis import kotlinx.atomicfu.atomic internal actual fun areObjectsOfSameType(a: Any, b: Any): Boolean { return a::class == b::class } +internal actual fun currentTimeMillis(): Long { + @Suppress("DEPRECATION") // TODO: Avoid using deprecated function + return getTimeMillis() +} + private val threadCounter = atomic(0L) @kotlin.native.concurrent.ThreadLocal private var threadId: Long = threadCounter.addAndGet(1) internal actual fun getCurrentThreadId(): Long = threadId - diff --git a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/node/WeakReference.native.kt b/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/node/WeakReference.native.kt index e95cd30c14d97..664b94a033aaf 100644 --- a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/node/WeakReference.native.kt +++ b/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/node/WeakReference.native.kt @@ -16,10 +16,8 @@ package androidx.compose.ui.node -// TODO mark internal once https://youtrack.jetbrains.com/issue/KT-36695 is fixed -actual class WeakReference actual constructor(referent: T) { +internal actual class WeakReference actual constructor(referent: T) { private val kotlinNativeReference = kotlin.native.ref.WeakReference(referent) actual fun get(): T? = kotlinNativeReference.get() actual fun clear() { kotlinNativeReference.clear() } } - diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt new file mode 100644 index 0000000000000..22bdf890fc3a2 --- /dev/null +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt @@ -0,0 +1,38 @@ +/* + * 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. + */ + +package androidx.compose.ui + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(DelicateCoroutinesApi::class) +internal actual fun postDelayed(delayMillis: Long, block: () -> Unit): Any { + // TODO https://youtrack.jetbrains.com/issue/CMP-7153/Remove-usage-of-the-main-thread-for-rect-tracking + return GlobalScope.launch(Dispatchers.Main) { + delay(delayMillis) + block() + } +} + +internal actual fun removePost(token: Any?) { + val job = token as? Job? + job?.cancel() +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentDataType.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentDataType.skiko.kt index 8b2c81ca3fe4a..7a26004e73eca 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentDataType.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentDataType.skiko.kt @@ -18,18 +18,17 @@ package androidx.compose.ui.autofill import kotlin.jvm.JvmInline +// TODO https://youtrack.jetbrains.com/issue/CMP-7154/Adopt-Autofill-semantic-properties + +actual typealias NativeContentDataType = Int + @JvmInline -internal actual value class ContentDataType actual constructor(actual val dataType: Int) { - internal actual companion object { - actual val Text: ContentDataType - get() = TODO("Not yet implemented") - actual val List: ContentDataType - get() = TODO("Not yet implemented") - actual val Date: ContentDataType - get() = TODO("Not yet implemented") - actual val Toggle: ContentDataType - get() = TODO("Not yet implemented") - actual val None: ContentDataType - get() = TODO("Not yet implemented") +actual value class ContentDataType actual constructor(val dataType: Int) { + actual companion object { + actual val Text = ContentDataType(1) + actual val List = ContentDataType(2) + actual val Date = ContentDataType(3) + actual val Toggle = ContentDataType(4) + actual val None = ContentDataType(0) } -} \ No newline at end of file +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentType.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentType.skiko.kt index 4423aa0380b67..dfe12443d61b2 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentType.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/autofill/ContentType.skiko.kt @@ -16,251 +16,45 @@ package androidx.compose.ui.autofill -/** - * Content type information. - * - * Autofill services use the [ContentType] to determine what value to use to autofill fields - * associated with this type. If the [ContentType] is not specified, the autofill services have - * to use heuristics to determine the right value to use while autofilling the corresponding field. - */ -// TODO(b/333102566): When Autofill goes live for Compose, -// these classes will need to be made public. -internal actual class ContentType private actual constructor(contentHint: String) { - internal actual companion object { - /** - * Indicates that the associated component can be autofilled with an email address. - */ - actual val EmailAddress: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a username. - */ - actual val Username: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a password. - */ - actual val Password: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be interpreted as a newly created username for - * save/update. - */ - actual val NewUsername: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be interpreted as a newly created password for - * save/update. - */ - actual val NewPassword: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a postal address. - */ - actual val PostalAddress: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a postal code. - */ - actual val PostalCode: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a credit card number. - */ - actual val CreditCardNumber: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a credit card security code. - */ - actual val CreditCardSecurityCode: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a credit card expiration date. - */ - actual val CreditCardExpirationDate: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a credit card expiration - * month. - */ - actual val CreditCardExpirationMonth: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a credit card expiration - * year. - */ - actual val CreditCardExpirationYear: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a credit card expiration day. - */ - actual val CreditCardExpirationDay: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a country name/code. - */ - actual val AddressCountry: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a region/state. - */ - actual val AddressRegion: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with an address locality - * (city/town). - */ - actual val AddressLocality: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a street address. - */ - actual val AddressStreet: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with auxiliary address details. - */ - actual val AddressAuxiliaryDetails: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with an extended ZIP/POSTAL code. - * - * Example: In forms that split the U.S. ZIP+4 Code with nine digits 99999-9999 into two - * fields annotate the delivery route code with this hint. - */ - actual val PostalCodeExtended: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a person's full name. - * - */ - actual val PersonFullName: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a person's first/given name. - */ - actual val PersonFirstName: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a person's last/family name. - */ - actual val PersonLastName: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a person's middle name. - */ - actual val PersonMiddleName: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a person's middle initial. - */ - actual val PersonMiddleInitial: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a person's name prefix. - */ - actual val PersonNamePrefix: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a person's name suffix. - */ - actual val PersonNameSuffix: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a phone number with - * country code. - * - * Example: +1 123-456-7890 - */ - actual val PhoneNumber: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with the current device's phone number - * usually for Sign Up / OTP flows. - */ - actual val PhoneNumberDevice: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a phone number's country code. - */ - actual val PhoneCountryCode: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a phone number without - * country code. - */ - actual val PhoneNumberNational: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a gender. - */ - actual val Gender: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a full birth date. - */ - actual val BirthDateFull: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a birth day(of the month). - */ - actual val BirthDateDay: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a birth day(of the month). - */ - actual val BirthDateMonth: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a birth year. - */ - actual val BirthDateYear: ContentType - get() = TODO("Not yet implemented") - - /** - * Indicates that the associated component can be autofilled with a SMS One Time Password (OTP). - */ - actual val SmsOtpCode: ContentType - get() = TODO("Not yet implemented") - - internal actual fun from(value: String): ContentType { - TODO("Not yet implemented") - } - +// TODO https://youtrack.jetbrains.com/issue/CMP-7154/Adopt-Autofill-semantic-properties + +actual class ContentType actual constructor(contentHint: String) { + actual companion object { + actual val Username: ContentType = throw NotImplementedError() + actual val Password: ContentType = throw NotImplementedError() + actual val EmailAddress: ContentType = throw NotImplementedError() + actual val NewUsername: ContentType = throw NotImplementedError() + actual val NewPassword: ContentType = throw NotImplementedError() + actual val PostalAddress: ContentType = throw NotImplementedError() + actual val PostalCode: ContentType = throw NotImplementedError() + actual val CreditCardNumber: ContentType = throw NotImplementedError() + actual val CreditCardSecurityCode: ContentType = throw NotImplementedError() + actual val CreditCardExpirationDate: ContentType = throw NotImplementedError() + actual val CreditCardExpirationMonth: ContentType = throw NotImplementedError() + actual val CreditCardExpirationYear: ContentType = throw NotImplementedError() + actual val CreditCardExpirationDay: ContentType = throw NotImplementedError() + actual val AddressCountry: ContentType = throw NotImplementedError() + actual val AddressRegion: ContentType = throw NotImplementedError() + actual val AddressLocality: ContentType = throw NotImplementedError() + actual val AddressStreet: ContentType = throw NotImplementedError() + actual val AddressAuxiliaryDetails: ContentType = throw NotImplementedError() + actual val PostalCodeExtended: ContentType = throw NotImplementedError() + actual val PersonFullName: ContentType = throw NotImplementedError() + actual val PersonFirstName: ContentType = throw NotImplementedError() + actual val PersonLastName: ContentType = throw NotImplementedError() + actual val PersonMiddleName: ContentType = throw NotImplementedError() + actual val PersonMiddleInitial: ContentType = throw NotImplementedError() + actual val PersonNamePrefix: ContentType = throw NotImplementedError() + actual val PersonNameSuffix: ContentType = throw NotImplementedError() + actual val PhoneNumber: ContentType = throw NotImplementedError() + actual val PhoneNumberDevice: ContentType = throw NotImplementedError() + actual val PhoneCountryCode: ContentType = throw NotImplementedError() + actual val PhoneNumberNational: ContentType = throw NotImplementedError() + actual val Gender: ContentType = throw NotImplementedError() + actual val BirthDateFull: ContentType = throw NotImplementedError() + actual val BirthDateDay: ContentType = throw NotImplementedError() + actual val BirthDateMonth: ContentType = throw NotImplementedError() + actual val BirthDateYear: ContentType = throw NotImplementedError() + actual val SmsOtpCode: ContentType = throw NotImplementedError() } - -} \ No newline at end of file +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.skiko.kt new file mode 100644 index 0000000000000..df2cc4fadf266 --- /dev/null +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/hapticfeedback/HapticFeedbackType.skiko.kt @@ -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. + */ + +package androidx.compose.ui.hapticfeedback + +// TODO +internal actual object PlatformHapticFeedbackType { + actual val Confirm = HapticFeedbackType(0) + actual val ContextClick = HapticFeedbackType(1) + actual val GestureEnd = HapticFeedbackType(2) + actual val GestureThresholdActivate = HapticFeedbackType(3) + actual val LongPress = HapticFeedbackType(4) + actual val Reject = HapticFeedbackType(5) + actual val SegmentFrequentTick = HapticFeedbackType(6) + actual val SegmentTick = HapticFeedbackType(7) + actual val TextHandleMove = HapticFeedbackType(8) + actual val ToggleOn = HapticFeedbackType(9) + actual val ToggleOff = HapticFeedbackType(10) + actual val VirtualKey = HapticFeedbackType(11) +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index 1c77be2f77583..17577d05b1488 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -16,18 +16,20 @@ package androidx.compose.ui.node +import androidx.collection.MutableIntObjectMap +import androidx.collection.mutableIntObjectMapOf import androidx.compose.runtime.collection.mutableVectorOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ComposeUiFlags import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill -import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusOwner import androidx.compose.ui.focus.FocusOwnerImpl @@ -36,8 +38,6 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Canvas -import androidx.compose.ui.graphics.GraphicsContext -import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.SkiaGraphicsContext import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.input.InputMode @@ -151,7 +151,7 @@ internal class RootNodeOwner( isTraversalGroup = true } val owner: Owner = OwnerImpl(layoutDirection, coroutineContext) - val semanticsOwner = SemanticsOwner(owner.root, rootSemanticsNode) + val semanticsOwner = SemanticsOwner(owner.root, rootSemanticsNode, owner.layoutNodes) var size: IntSize? = size set(value) { field = value @@ -281,7 +281,7 @@ internal class RootNodeOwner( */ fun hitTestInteropView(position: Offset): InteropView? { val result = HitTestResult() - owner.root.hitTest(position, result, true) + owner.root.hitTest(position, result, isInLayer = true) val last = result.lastOrNull() as? BackwardsCompatNode val node = last?.element as? InteropPointerInputModifier @@ -317,6 +317,7 @@ internal class RootNodeOwner( } override val sharedDrawScope = LayoutNodeDrawScope() + override val layoutNodes: MutableIntObjectMap = mutableIntObjectMapOf() override val rootForTest get() = this@RootNodeOwner.rootForTest override val hapticFeedBack = DefaultHapticFeedback() override val inputModeManager get() = platformContext.inputModeManager @@ -326,8 +327,8 @@ internal class RootNodeOwner( override val textToolbar get() = platformContext.textToolbar override val autofillTree = AutofillTree() override val autofill: Autofill? get() = null - // TODO https://youtrack.jetbrains.com/issue/CMP-1572/Support-SemanticAutofill - override val semanticAutofill: SemanticAutofill? get() = null + // TODO https://youtrack.jetbrains.com/issue/CMP-1572 + override val autofillManager: AutofillManager? get() = null override val density get() = this@RootNodeOwner.density override val textInputService = TextInputService(platformContext.textInputService) @@ -340,9 +341,11 @@ internal class RootNodeOwner( override val dragAndDropManager = this@RootNodeOwner.dragAndDropOwner override val pointerIconService = PointerIconServiceImpl() + override val semanticsOwner get() = this@RootNodeOwner.semanticsOwner override val focusOwner get() = this@RootNodeOwner.focusOwner override val windowInfo get() = platformContext.windowInfo // TODO: 1.8.0-alpha02 Implement ComposeUiFlags.isRectTrackingEnabled + // https://youtrack.jetbrains.com/issue/CMP-6715/Support-ComposeUiFlags.isRectTrackingEnabled override val rectManager = RectManager() @Suppress("OVERRIDE_DEPRECATION", "DEPRECATION") @@ -359,8 +362,13 @@ internal class RootNodeOwner( override val measureIteration: Long get() = measureAndLayoutDelegate.measureIteration override fun requestFocus() = platformContext.requestFocus() - override fun onAttach(node: LayoutNode) = Unit + + override fun onAttach(node: LayoutNode) { + layoutNodes[node.semanticsId] = node + } + override fun onDetach(node: LayoutNode) { + layoutNodes.remove(node.semanticsId) measureAndLayoutDelegate.onNodeDetached(node) snapshotObserver.clear(node) needClearObservations = true @@ -657,6 +665,10 @@ internal class RootNodeOwner( desiredPointerIcon = value platformContext.setPointerIcon(desiredPointerIcon ?: PointerIcon.Default) } + + // TODO https://youtrack.jetbrains.com/issue/CMP-7145/Properly-adopt-stylus-handwriting-hover-icon + override fun getStylusHoverIcon(): PointerIcon? = null + override fun setStylusHoverIcon(value: PointerIcon?) {} } } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/SnapshotInvalidationTracker.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/SnapshotInvalidationTracker.skiko.kt index b1ba0ea1d372b..bd3465ab0b528 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/SnapshotInvalidationTracker.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/SnapshotInvalidationTracker.skiko.kt @@ -17,7 +17,7 @@ package androidx.compose.ui.node import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.platform.createSynchronizedObject +import androidx.compose.ui.platform.makeSynchronizedObject import androidx.compose.ui.getCurrentThreadId import androidx.compose.ui.platform.synchronized import kotlinx.atomicfu.atomic @@ -105,7 +105,7 @@ internal class SnapshotInvalidationTracker( private class CommandList( private var onNewCommand: () -> Unit ) { - private val sync = createSynchronizedObject() + private val lock = makeSynchronizedObject() private val list = mutableListOf<() -> Unit>() private val listCopy = mutableListOf<() -> Unit>() @@ -114,7 +114,7 @@ private class CommandList( * * Can be called concurrently from multiple threads. */ - val hasCommands: Boolean get() = synchronized(sync) { + val hasCommands: Boolean get() = synchronized(lock) { list.isNotEmpty() } @@ -124,7 +124,7 @@ private class CommandList( * Can be called concurrently from multiple threads. */ fun add(command: () -> Unit) { - synchronized(sync) { + synchronized(lock) { list.add(command) } onNewCommand() @@ -137,7 +137,7 @@ private class CommandList( * and concurrent [add]. */ fun perform() { - synchronized(sync) { + synchronized(lock) { listCopy.addAll(list) list.clear() } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/FlushCoroutineDispatcher.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/FlushCoroutineDispatcher.skiko.kt index e5b4b09705854..fcdbade02d9ce 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/FlushCoroutineDispatcher.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/FlushCoroutineDispatcher.skiko.kt @@ -42,12 +42,12 @@ internal class FlushCoroutineDispatcher( private val scope = CoroutineScope(scope.coroutineContext.minusKey(Job)) private var immediateTasks = ArrayDeque() private val delayedTasks = ArrayDeque() - private val immediateTasksLock = createSynchronizedObject() - private val delayedTasksLock = createSynchronizedObject() + private val immediateTasksLock = makeSynchronizedObject() + private val delayedTasksLock = makeSynchronizedObject() private var immediateTasksSwap = ArrayDeque() @Volatile private var isPerformingRun = false - private val runLock = createSynchronizedObject() + private val runLock = makeSynchronizedObject() override fun dispatch(context: CoroutineContext, block: Runnable) { synchronized(immediateTasksLock) { diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/Synchronization.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/Synchronization.skiko.kt new file mode 100644 index 0000000000000..170c16393e806 --- /dev/null +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/Synchronization.skiko.kt @@ -0,0 +1,35 @@ +/* + * 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. + */ +@file:JvmName("SynchronizationKt") + +package androidx.compose.ui.platform + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.jvm.JvmName + +internal actual class SynchronizedObject : kotlinx.atomicfu.locks.SynchronizedObject() + +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + +@PublishedApi +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +internal actual inline fun synchronized(lock: SynchronizedObject, block: () -> R): R { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + return kotlinx.atomicfu.locks.synchronized(lock, block) +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/WindowInfo.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/WindowInfo.skiko.kt deleted file mode 100644 index 865ff5332ef34..0000000000000 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/WindowInfo.skiko.kt +++ /dev/null @@ -1,66 +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 androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.input.pointer.EmptyPointerKeyboardModifiers -import androidx.compose.ui.input.pointer.PointerKeyboardModifiers -import androidx.compose.ui.internal.JvmDefaultWithCompatibility -import androidx.compose.ui.unit.IntSize - -/** Provides information about the Window that is hosting this compose hierarchy. */ -@Stable -@JvmDefaultWithCompatibility -actual interface WindowInfo { - /** - * Indicates whether the window hosting this compose hierarchy is in focus. - * - * When there are multiple windows visible, either in a multi-window environment or if a - * popup or dialog is visible, this property can be used to determine if the current window - * is in focus. - */ - actual val isWindowFocused: Boolean - - /** Indicates the state of keyboard modifiers (pressed or not). */ - @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") - actual val keyboardModifiers: PointerKeyboardModifiers - get() = WindowInfoImpl.GlobalKeyboardModifiers.value - - /** Size of the window's content container in pixels. */ - @ExperimentalComposeUiApi - val containerSize: IntSize get() = IntSize.Zero -} - -internal class WindowInfoImpl : WindowInfo { - override var isWindowFocused: Boolean by mutableStateOf(false) - - @ExperimentalComposeUiApi - override var keyboardModifiers: PointerKeyboardModifiers by GlobalKeyboardModifiers - - @ExperimentalComposeUiApi - override var containerSize: IntSize by mutableStateOf(IntSize.Zero) - - companion object { - // One instance across all windows makes sense, since the state of KeyboardModifiers is - // common for all windows. - internal val GlobalKeyboardModifiers = mutableStateOf(EmptyPointerKeyboardModifiers()) - } -} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/LayersAccessibilityTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/LayersAccessibilityTest.kt deleted file mode 100644 index 08151a227c0e0..0000000000000 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/LayersAccessibilityTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.ui.accessibility - -import androidx.compose.material.Text -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.assertAccessibilityTree -import androidx.compose.ui.test.runUIKitInstrumentedTest -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import kotlin.test.Test - -class LayersAccessibilityTest { - - @Test - fun testNodesCoveredByPopup() = runUIKitInstrumentedTest { - val topPopup = mutableStateOf(false) - val bottomPopup = mutableStateOf(false) - val topPopupFocusable = mutableStateOf(false) - setContentWithAccessibilityEnabled { - Text("Root") - if (bottomPopup.value) { - Popup { - Text("Popup 1") - } - } - if (topPopup.value) { - Popup(properties = PopupProperties(focusable = topPopupFocusable.value)) { - Text("Popup 2") - } - } - } - - assertAccessibilityTree { - label = "Root" - } - - bottomPopup.value = true - // Non-focusable popup should not hide content under it for accessibility reader - assertAccessibilityTree { - node { - label = "Root" - } - node { - label = "Popup 1" - } - } - - topPopup.value = true - // Non-focusable popup should not hide content under it for accessibility reader - assertAccessibilityTree { - node { - label = "Root" - } - node { - label = "Popup 1" - } - node { - label = "Popup 2" - } - } - - topPopupFocusable.value = true - // Popup should react on focusable flag change - assertAccessibilityTree { - label = "Popup 2" - } - - topPopup.value = false - bottomPopup.value = false - assertAccessibilityTree { - label = "Root" - } - } - - @Test - fun testNodesCoveredByDialog() = runUIKitInstrumentedTest { - val showDialog = mutableStateOf(false) - setContentWithAccessibilityEnabled { - Text("Root") - Popup { - Text("Popup") - } - if (showDialog.value) { - Dialog(onDismissRequest = {}) { - Text("Dialog") - } - } - } - - assertAccessibilityTree { - node { - label = "Root" - } - node { - label = "Popup" - } - } - - showDialog.value = true - // Dialog popup should hide content under it for accessibility reader - assertAccessibilityTree { - label = "Dialog" - } - - showDialog.value = false - - assertAccessibilityTree { - node { - label = "Root" - } - node { - label = "Popup" - } - } - } - - @Test - fun testLayersAppearanceOrder() = runUIKitInstrumentedTest { - val bottomLayer = mutableStateOf(false) - val middleLayers = mutableStateOf(false) - setContentWithAccessibilityEnabled { - Text("Root") - if (bottomLayer.value) { - Popup(properties = PopupProperties(focusable = true)) { - Text("Bottom") - } - } - if (middleLayers.value) { - Popup(properties = PopupProperties(focusable = true)) { - Text("Middle 1") - } - // Non-focusable layer - Popup { - Text("Middle 2") - } - } - Popup(properties = PopupProperties(focusable = true)) { - Text("Top") - } - } - - assertAccessibilityTree { - label = "Top" - } - - bottomLayer.value = true - // The last added layer should be on top - assertAccessibilityTree { - label = "Bottom" - } - - middleLayers.value = true - // The last added layers should be on top - assertAccessibilityTree { - node { - label = "Middle 1" - } - node { - label = "Middle 2" - } - } - } -} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt deleted file mode 100644 index 36fbb2f0252dd..0000000000000 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt +++ /dev/null @@ -1,311 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.ui.test - -import androidx.compose.ui.uikit.toDpRect -import androidx.compose.ui.unit.DpRect -import kotlin.test.assertEquals -import kotlinx.cinterop.ExperimentalForeignApi -import platform.UIKit.UIAccessibilityElement -import platform.UIKit.UIAccessibilityTraitAdjustable -import platform.UIKit.UIAccessibilityTraitAllowsDirectInteraction -import platform.UIKit.UIAccessibilityTraitButton -import platform.UIKit.UIAccessibilityTraitCausesPageTurn -import platform.UIKit.UIAccessibilityTraitHeader -import platform.UIKit.UIAccessibilityTraitImage -import platform.UIKit.UIAccessibilityTraitKeyboardKey -import platform.UIKit.UIAccessibilityTraitLink -import platform.UIKit.UIAccessibilityTraitNone -import platform.UIKit.UIAccessibilityTraitNotEnabled -import platform.UIKit.UIAccessibilityTraitPlaysSound -import platform.UIKit.UIAccessibilityTraitSearchField -import platform.UIKit.UIAccessibilityTraitSelected -import platform.UIKit.UIAccessibilityTraitStartsMediaSession -import platform.UIKit.UIAccessibilityTraitStaticText -import platform.UIKit.UIAccessibilityTraitSummaryElement -import platform.UIKit.UIAccessibilityTraitSupportsZoom -import platform.UIKit.UIAccessibilityTraitTabBar -import platform.UIKit.UIAccessibilityTraitToggleButton -import platform.UIKit.UIAccessibilityTraitUpdatesFrequently -import platform.UIKit.UIAccessibilityTraits -import platform.UIKit.UICollectionView -import platform.UIKit.UITableView -import platform.UIKit.UIView -import platform.UIKit.accessibilityCustomActions -import platform.UIKit.accessibilityElementAtIndex -import platform.UIKit.accessibilityElementCount -import platform.UIKit.accessibilityElements -import platform.UIKit.accessibilityFrame -import platform.UIKit.accessibilityLabel -import platform.UIKit.accessibilityTraits -import platform.UIKit.accessibilityValue -import platform.UIKit.isAccessibilityElement -import platform.darwin.NSIntegerMax -import platform.darwin.NSObject - -/** - * Constructs an accessibility tree representation of the UI hierarchy starting from the window. - * - * This function traverses the accessibility elements and their children to build a structured - * node tree with information about accessibility properties, allowing for analysis and testing - * of the accessibility features of the UI. - * - * @return The root node of the accessibility tree representing the current UI hierarchy, - * or null if the tree cannot be constructed. - */ -@OptIn(ExperimentalForeignApi::class) -internal fun UIKitInstrumentedTest.getAccessibilityTree(): AccessibilityTestNode { - fun buildNode(element: NSObject, level: Int): AccessibilityTestNode { - val children = mutableListOf() - val elements = element.accessibilityElements() - - if (elements != null) { - elements.forEach { - children.add(buildNode(it as NSObject, level = level + 1)) - } - } else { - val count = element.accessibilityElementCount() - if (count == NSIntegerMax) { - when (element) { - is UITableView -> { - TODO("Unused in tests. Implement correct table view traversal.") - } - - is UICollectionView -> { - TODO("Unused in tests. Implement correct collection view traversal.") - } - - else -> { - error("Unsupported element: $element of type ${element::class}") - } - } - } else if (count > 0) { - (0 until count).mapNotNull { - val child = element.accessibilityElementAtIndex(it) as NSObject - children.add(buildNode(child, level = level + 1)) - } - } else if (element is UIView) { - element.subviews.mapNotNull { - children.add(buildNode(it as UIView, level = level + 1)) - } - } - } - - return AccessibilityTestNode( - isAccessibilityElement = element.isAccessibilityElement, - identifier = (element as? UIAccessibilityElement)?.accessibilityIdentifier, - label = element.accessibilityLabel, - value = element.accessibilityValue, - frame = element.accessibilityFrame.toDpRect(), - children = children, - traits = allAccessibilityTraits.keys.filter { - element.accessibilityTraits and it != 0.toULong() - }, - element = element - ) - } - - return buildNode(window, 0) -} - -private val allAccessibilityTraits = mapOf( - UIAccessibilityTraitNone to "UIAccessibilityTraitNone", - UIAccessibilityTraitButton to "UIAccessibilityTraitButton", - UIAccessibilityTraitLink to "UIAccessibilityTraitLink", - UIAccessibilityTraitHeader to "UIAccessibilityTraitHeader", - UIAccessibilityTraitSearchField to "UIAccessibilityTraitSearchField", - UIAccessibilityTraitImage to "UIAccessibilityTraitImage", - UIAccessibilityTraitSelected to "UIAccessibilityTraitSelected", - UIAccessibilityTraitPlaysSound to "UIAccessibilityTraitPlaysSound", - UIAccessibilityTraitKeyboardKey to "UIAccessibilityTraitKeyboardKey", - UIAccessibilityTraitStaticText to "UIAccessibilityTraitStaticText", - UIAccessibilityTraitSummaryElement to "UIAccessibilityTraitSummaryElement", - UIAccessibilityTraitNotEnabled to "UIAccessibilityTraitNotEnabled", - UIAccessibilityTraitUpdatesFrequently to "UIAccessibilityTraitUpdatesFrequently", - UIAccessibilityTraitStartsMediaSession to "UIAccessibilityTraitStartsMediaSession", - UIAccessibilityTraitAdjustable to "UIAccessibilityTraitAdjustable", - UIAccessibilityTraitAllowsDirectInteraction to "UIAccessibilityTraitAllowsDirectInteraction", - UIAccessibilityTraitCausesPageTurn to "UIAccessibilityTraitCausesPageTurn", - UIAccessibilityTraitTabBar to "UIAccessibilityTraitTabBar", - UIAccessibilityTraitToggleButton to "UIAccessibilityTraitToggleButton", - UIAccessibilityTraitSupportsZoom to "UIAccessibilityTraitSupportsZoom" -) - -/** - * Represents a node in an accessibility tree, which is used for testing accessibility features - * within a UI hierarchy. This class captures various accessibility properties of UI components - * and structures them into a tree. - */ -data class AccessibilityTestNode( - var isAccessibilityElement: Boolean? = null, - var identifier: String? = null, - var label: String? = null, - var value: String? = null, - var frame: DpRect? = null, - var children: List? = null, - var traits: List? = null, - var element: NSObject? = null -) { - fun node(builder: AccessibilityTestNode.() -> Unit) { - children = (children ?: emptyList()) + AccessibilityTestNode().apply(builder) - } - - fun traits(vararg trait: UIAccessibilityTraits) { - traits = (traits ?: emptyList()) + trait - } - - fun validate(actualNode: AccessibilityTestNode?) { - isAccessibilityElement?.let { - assertEquals(it, actualNode?.isAccessibilityElement) - } - identifier?.let { - assertEquals(it, actualNode?.identifier) - } - label?.let { - assertEquals(it, actualNode?.label) - } - value?.let { - assertEquals(it, actualNode?.value) - } - frame?.let { - assertEquals(it, actualNode?.frame) - } - traits?.let { - assertEquals(it.toSet(), actualNode?.traits?.toSet()) - } - children?.let { - assertEquals(it.count(), actualNode?.children?.count()) - it.zip(actualNode?.children ?: emptyList()) { validator, child -> - validator.validate(child) - } - } - } - - val hasAccessibilityComponents: Boolean = identifier != null || - isAccessibilityElement == true || - label != null || - value != null || - traits?.isNotEmpty() == true - - fun printTree(): String { - val builder = StringBuilder() - - fun print(node: AccessibilityTestNode, level: Int) { - val indent = " ".repeat(level) - builder.append(indent) - builder.append(node.label ?: node.identifier ?: "other") - builder.append(" - ${node.frame}") - node.element?.let { - builder.append(" - <${it::class}>") - } - builder.appendLine() - - val fieldIndent = "$indent |" - if (node.isAccessibilityElement == true) { - builder.appendLine("$fieldIndent isAccessibilityElement: true") - } - node.identifier?.let { - builder.appendLine("$fieldIndent accessibilityIdentifier: $it") - } - node.label?.let { builder.appendLine("$fieldIndent accessibilityLabel: $it") } - if (node.traits?.isNotEmpty() == true) { - builder.appendLine("$fieldIndent accessibilityTraits:") - node.traits?.forEach { - builder.appendLine("$fieldIndent - ${allAccessibilityTraits.getValue(it)}") - } - } - node.value?.let { builder.appendLine("$fieldIndent accessibilityValue: $it") } - node.element?.accessibilityCustomActions?.takeIf { it.isNotEmpty() }?.let { - builder.appendLine("$fieldIndent accessibilityCustomActions: $it") - } - - node.children?.forEach { print(it, level + 1) } - } - print(this, level = 0) - - return builder.toString() - } -} - -/** - * Normalizes the accessibility nodes tree by analyzing its properties and children. - * Removes all element that are not accessibility elements or does not work as elements containers. - */ -internal fun AccessibilityTestNode.normalized(): AccessibilityTestNode? { - val normalizedChildren = children?.flatMap { - it.normalized()?.let { - if (it.hasAccessibilityComponents) { - listOf(it) - } else { - it.children - } - } ?: emptyList() - } ?: emptyList() - - return if (hasAccessibilityComponents || normalizedChildren.count() > 1) { - this.copy(children = normalizedChildren) - } else if (normalizedChildren.count() == 1) { - normalizedChildren.single() - } else { - null - } -} - -/** - * Asserts that the current accessibility tree matches the expected structure defined in the - * provided lambda. The expected structure is defined by configuring an `AccessibilityTestNode`, - * which is then validated against the actual normalized accessibility tree. This function waits - * for the UI to be idle before performing the validation. - * - * @param expected A lambda that allows the caller to specify the expected structure and properties - * of the accessibility tree. - */ -internal fun UIKitInstrumentedTest.assertAccessibilityTree( - expected: AccessibilityTestNode.() -> Unit -) { - val validator = AccessibilityTestNode() - with(validator, expected) - assertAccessibilityTree(validator) -} - -/** - * Asserts that the current accessibility tree matches the expected structure defined in the - * provided lambda. The expected structure is defined by configuring an `AccessibilityTestNode`, - * which is then validated against the actual normalized accessibility tree. This function waits - * for the UI to be idle before performing the validation. - * - * @param expected The expected accessibility tree structure represented by an instance of - * `AccessibilityTestNode`. - */ -internal fun UIKitInstrumentedTest.assertAccessibilityTree(expected: AccessibilityTestNode) { - waitForIdle() - - val actualTreeRoot = getAccessibilityTree() - val normalizedTree = actualTreeRoot.normalized() - - try { - expected.validate(normalizedTree) - } catch (e: Throwable) { - val message = "Unable to validate accessibility tree. Expected normalized tree:\n\n" + - "${expected.printTree()}\n" + - "Normalized tree:\n\n${normalizedTree?.printTree()}\n" + - "Actual tree:\n\n${actualTreeRoot.printTree()}\n" - println(message) - - throw e - } -} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 23c48fbdd2d4d..2c81f2056f643 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -17,9 +17,7 @@ package androidx.compose.ui.test import androidx.compose.runtime.Composable -import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.platform.AccessibilitySyncOptions import androidx.compose.ui.platform.InfiniteAnimationPolicy import androidx.compose.ui.scene.ComposeHostingViewController import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration @@ -96,11 +94,6 @@ internal class UIKitInstrumentedTest { private val coroutineContext = Dispatchers.Main + infiniteAnimationPolicy - @OptIn(ExperimentalComposeApi::class) - fun setContentWithAccessibilityEnabled(content: @Composable () -> Unit) { - setContent({ accessibilitySyncOptions = AccessibilitySyncOptions.Always }, content) - } - fun setContent( configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, content: @Composable () -> Unit diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt index 340b77af03b04..5caa6cc842aa2 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt @@ -17,7 +17,6 @@ package androidx.compose.ui.platform import androidx.compose.runtime.ExperimentalComposeApi -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.node.LayoutNode @@ -30,7 +29,6 @@ import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsProperties.HideFromAccessibility -import androidx.compose.ui.semantics.SemanticsProperties.InvisibleToUser import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.state.ToggleableState @@ -45,9 +43,9 @@ import kotlin.time.measureTime import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue import kotlinx.cinterop.ExportObjCClass +import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.readValue import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -57,6 +55,8 @@ import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectZero import platform.Foundation.NSNotFound +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSSelectorFromString import platform.UIKit.NSStringFromCGRect import platform.UIKit.UIAccessibilityCustomAction import platform.UIKit.UIAccessibilityFocusedElement @@ -79,6 +79,8 @@ import platform.UIKit.UIAccessibilityTraitNotEnabled import platform.UIKit.UIAccessibilityTraitSelected import platform.UIKit.UIAccessibilityTraitUpdatesFrequently import platform.UIKit.UIAccessibilityTraits +import platform.UIKit.UIAccessibilityVoiceOverStatusChanged +import platform.UIKit.UIAccessibilityVoiceOverStatusDidChangeNotification import platform.UIKit.UIView import platform.UIKit.UIWindow import platform.UIKit.accessibilityCustomActions @@ -109,7 +111,7 @@ private class CachedAccessibilityPropertyKey private object CachedAccessibilityPropertyKeys { val accessibilityLabel = CachedAccessibilityPropertyKey() val isAccessibilityElement = CachedAccessibilityPropertyKey() - val accessibilityIdentifier = CachedAccessibilityPropertyKey() + val accessibilityIdentifier = CachedAccessibilityPropertyKey() val accessibilityHint = CachedAccessibilityPropertyKey() val accessibilityCustomActions = CachedAccessibilityPropertyKey>() val accessibilityTraits = CachedAccessibilityPropertyKey() @@ -595,12 +597,30 @@ private class AccessibilityElement( override fun isAccessibilityElement(): Boolean = getOrElse(CachedAccessibilityPropertyKeys.isAccessibilityElement) { - semanticsNode.isAccessibilityElement + val config = cachedConfig + + if (config.contains(SemanticsProperties.InvisibleToUser) || + config.contains(HideFromAccessibility) + ) { + false + } else { + // TODO: investigate if it can it be one of those _and_ contain properties that should + // be communicated to iOS? + if (config.getOrNull(SemanticsProperties.IsTraversalGroup) == true + || config.contains(SemanticsProperties.IsPopup) + || config.contains(SemanticsProperties.IsDialog) + ) { + false + } else { + config.containsImportantForAccessibility() + } + } } - override fun accessibilityIdentifier(): String? = + override fun accessibilityIdentifier(): String = getOrElse(CachedAccessibilityPropertyKeys.accessibilityIdentifier) { cachedConfig.getOrNull(SemanticsProperties.TestTag) + ?: "AccessibilityElement for SemanticsNode(id=$semanticsNodeId)" } override fun accessibilityHint(): String? = @@ -1009,7 +1029,7 @@ private val accessibilityDebugLogger: AccessibilityDebugLogger? = null // } @OptIn(ExperimentalComposeApi::class) -internal val AccessibilitySyncOptions.isGlobalAccessibilityEnabled +private val AccessibilitySyncOptions.shouldPerformSync get() = when (this) { AccessibilitySyncOptions.Never -> false @@ -1020,10 +1040,13 @@ internal val AccessibilitySyncOptions.isGlobalAccessibilityEnabled /** * A class responsible for mediating between the tree of specific SemanticsOwner and the iOS accessibility tree. */ +@OptIn(ExperimentalComposeApi::class) internal class AccessibilityMediator( val view: UIView, val owner: SemanticsOwner, coroutineContext: CoroutineContext, + private val getAccessibilitySyncOptions: () -> AccessibilitySyncOptions, + /** * A function that converts the given [Rect] from the semantics tree coordinate space (window container for layers) * to the [CGRect] in coordinate space of the app window. @@ -1042,6 +1065,8 @@ internal class AccessibilityMediator( private val needsRedundantRefocusingOnSameElement: Boolean get() = inflightScrollsCount > 0 + private val notificationCenter = NSNotificationCenter.defaultCenter + /** * The kind of invalidation that determines what kind of logic will be executed in the next sync. * `COMPLETE` invalidation means that the whole tree should be recomputed, `BOUNDS` means that only @@ -1083,18 +1108,16 @@ internal class AccessibilityMediator( */ private val accessibilityElementsMap = mutableMapOf() - var isEnabled: Boolean = false - set(value) { - if (field != value) { - field = value - onSemanticsChange() - } - } - init { accessibilityDebugLogger?.log("AccessibilityMediator for $view created") - view.accessibilityElements = listOf() + notificationCenter.addObserver( + observer = this, + selector = NSSelectorFromString(::voiceOverStatusDidChange.name), + name = UIAccessibilityVoiceOverStatusDidChangeNotification, + `object` = null + ) + coroutineScope.launch { // The main loop that listens for invalidations and performs the tree syncing // Will exit on CancellationException from within await on `invalidationChannel.receive()` @@ -1107,9 +1130,13 @@ internal class AccessibilityMediator( // Workaround for the channel buffering two invalidations despite the capacity of 1 } - debugLogger = accessibilityDebugLogger.takeIf { isEnabled } + val syncOptions = getAccessibilitySyncOptions() + + val shouldPerformSync = syncOptions.shouldPerformSync + + debugLogger = accessibilityDebugLogger.takeIf { shouldPerformSync } - if (isEnabled) { + if (shouldPerformSync) { var result: NodesSyncResult val time = measureTime { @@ -1120,11 +1147,6 @@ internal class AccessibilityMediator( debugLogger?.log("LayoutChanged, newElementToFocus: ${result.newElementToFocus}") UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, result.newElementToFocus) - } else { - if (view.accessibilityElements?.isEmpty() != true) { - view.accessibilityElements = listOf() - UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, null) - } } invalidationKind = SemanticsTreeInvalidationKind.BOUNDS @@ -1133,8 +1155,12 @@ internal class AccessibilityMediator( } } - @OptIn(ExperimentalCoroutinesApi::class) - val hasPendingInvalidations: Boolean get() = !invalidationChannel.isEmpty + @OptIn(BetaInteropApi::class) + @ObjCAction + private fun voiceOverStatusDidChange() { + invalidationKind = SemanticsTreeInvalidationKind.COMPLETE + invalidationChannel.trySend(Unit) + } fun convertToAppWindowCGRect(rect: Rect): CValue { val window = view.window ?: return CGRectZero.readValue() @@ -1202,11 +1228,17 @@ internal class AccessibilityMediator( job.cancel() isAlive = false - view.accessibilityElements = listOf() + view.accessibilityElements = null for (element in accessibilityElementsMap.values) { element.dispose() } + + notificationCenter.removeObserver( + observer = this, + name = UIAccessibilityVoiceOverStatusChanged, + `object` = null + ) } private fun createOrUpdateAccessibilityElementForSemanticsNode(node: SemanticsNode): AccessibilityElement { @@ -1479,39 +1511,6 @@ private val SemanticsNode.isValid: Boolean private val SemanticsNode.isRTL: Boolean get() = layoutInfo.layoutDirection == LayoutDirection.Rtl -private val SemanticsNode.isAccessibilityElement: Boolean get() { - val config = this.config - - @Suppress("DEPRECATION") - return if (isHidden) { - false - } else { - if (config.getOrNull(SemanticsProperties.IsTraversalGroup) == true - || config.contains(SemanticsProperties.IsPopup) - || config.contains(SemanticsProperties.IsDialog) - ) { - false - } else if (config.getOrNull(SemanticsProperties.IsContainer) == true && - config.props.size == 1 - ) { - false - } else { - config.containsImportantForAccessibility() - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("DEPRECATION") -internal val SemanticsNode.isHidden: Boolean - // A node is considered hidden if it is transparent, or explicitly is hidden from accessibility. - // This also checks if the node has been marked as `invisibleToUser`, which is what the - // `hiddenFromAccessibility` API used to be named. - get() = - isTransparent || - (unmergedConfig.contains(HideFromAccessibility) || - unmergedConfig.contains(InvisibleToUser)) - /** * Closest ancestor that has [SemanticsActions.ScrollBy] action */ diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt index 2f1b03bb82391..9850b1cd13cdc 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.platform +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.DpOffset internal interface IOSSkikoInput { @@ -26,16 +27,6 @@ internal interface IOSSkikoInput { fun endFloatingCursor() - /** - * Delays all edit commands until [endEditBatch] is being called. - */ - fun beginEditBatch() - - /** - * Performs all editing commands, starting from the [beginEditBatch] call. - */ - fun endEditBatch() - /** * A Boolean value that indicates whether the text-entry object has any text. * https://developer.apple.com/documentation/uikit/uikeyinput/1614457-hastext @@ -125,4 +116,23 @@ internal interface IOSSkikoInput { * Returned value must be in range between 0 and length of text (inclusive). */ fun positionFromPosition(position: Long, offset: Long): Long + + object Empty : IOSSkikoInput { + override fun beginFloatingCursor(offset: DpOffset) = Unit + override fun updateFloatingCursor(offset: DpOffset) = Unit + override fun endFloatingCursor() = Unit + override fun hasText(): Boolean = false + override fun insertText(text: String) = Unit + override fun deleteBackward() = Unit + override fun endOfDocument(): Long = 0L + override fun getSelectedTextRange(): IntRange? = null + override fun setSelectedTextRange(range: IntRange?) = Unit + override fun selectAll() = Unit + override fun textInRange(range: IntRange): String = "" + override fun replaceRange(range: IntRange, text: String) = Unit + override fun setMarkedText(markedText: String?, selectedRange: IntRange) = Unit + override fun markedTextRange(): IntRange? = null + override fun unmarkText() = Unit + override fun positionFromPosition(position: Long, offset: Long): Long = 0 + } } \ No newline at end of file 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 87fa885ad75a1..6d4aa26910aeb 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 @@ -162,7 +162,6 @@ internal class UIKitTextInputService( } override fun stopInput() { - flushEditCommandsIfNeeded(force = true) currentInput = null _tempCurrentInputSession = null currentImeOptions = null @@ -264,27 +263,10 @@ internal class UIKitTextInputService( return event.type == KeyEventType.KeyDown } - private val editCommandsBatch = mutableListOf() - private var editBatchDepth: Int = 0 - set(value) { - field = value - flushEditCommandsIfNeeded() - } - private fun sendEditCommand(vararg commands: EditCommand) { - _tempCurrentInputSession?.apply(commands.toList()) - - editCommandsBatch.addAll(commands) - flushEditCommandsIfNeeded() - } - - fun flushEditCommandsIfNeeded(force: Boolean = false) { - if ((force || editBatchDepth == 0) && editCommandsBatch.isNotEmpty()) { - val commandList = editCommandsBatch.toList() - editCommandsBatch.clear() - - currentInput?.onEditCommand?.invoke(commandList) - } + val commandList = commands.toList() + _tempCurrentInputSession?.apply(commandList) + currentInput?.onEditCommand?.invoke(commandList) } private fun getCursorPos(): Int? { @@ -413,14 +395,6 @@ internal class UIKitTextInputService( floatingCursorTranslation = null } - override fun beginEditBatch() { - editBatchDepth++ - } - - override fun endEditBatch() { - editBatchDepth-- - } - /** * A Boolean value that indicates whether the text-entry object has any text. * https://developer.apple.com/documentation/uikit/uikeyinput/1614457-hastext diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt index ac7f211d32deb..816f53e5d1b88 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalInternalViewModelStoreOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext -import androidx.compose.ui.platform.isGlobalAccessibilityEnabled import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.LocalInterfaceOrientation @@ -42,7 +41,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.asDpRect import androidx.compose.ui.unit.roundToIntRect -import androidx.compose.ui.util.fastForEachReversed import androidx.compose.ui.viewinterop.UIKitInteropAction import androidx.compose.ui.viewinterop.UIKitInteropTransaction import androidx.compose.ui.window.ComposeView @@ -60,7 +58,6 @@ import kotlin.time.toDuration import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue import kotlinx.cinterop.ExportObjCClass -import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -69,10 +66,6 @@ import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available import platform.CoreGraphics.CGSize -import platform.Foundation.NSNotificationCenter -import platform.Foundation.NSSelectorFromString -import platform.UIKit.UIAccessibilityVoiceOverStatusChanged -import platform.UIKit.UIAccessibilityVoiceOverStatusDidChangeNotification import platform.UIKit.UIApplication import platform.UIKit.UIEvent import platform.UIKit.UIStatusBarAnimation @@ -186,13 +179,6 @@ internal class ComposeHostingViewController( configuration.delegate.viewDidLoad() systemThemeState.value = traitCollection.userInterfaceStyle.asComposeSystemTheme() - - NSNotificationCenter.defaultCenter.addObserver( - observer = this, - selector = NSSelectorFromString(::onAccessibilityChanged.name), - name = UIAccessibilityVoiceOverStatusDidChangeNotification, - `object` = null - ) } override fun viewDidLayoutSubviews() { @@ -344,8 +330,7 @@ internal class ComposeHostingViewController( onGestureEvent = layers::onGestureEvent, initDensity = density, initLayoutDirection = layoutDirection, - onFocusBehavior = configuration.onFocusBehavior, - onAccessibilityChanged = ::onAccessibilityChanged, + configuration = configuration, focusStack = if (focusable) focusStack else null, windowContext = windowContext, compositionContext = compositionContext, @@ -375,13 +360,12 @@ internal class ComposeHostingViewController( private fun createMediatorIfNeeded() { if (mediator == null) { mediator = createMediator() - onAccessibilityChanged() } } private fun createMediator() = ComposeSceneMediator( parentView = rootView, - onFocusBehavior = configuration.onFocusBehavior, + configuration = configuration, focusStack = focusStack, windowContext = windowContext, coroutineContext = coroutineContext, @@ -397,23 +381,6 @@ internal class ComposeHostingViewController( rootView.bringSubviewToFront(rootMetalView) } - /** - * Enables or disables accessibility for each layer, as well as the root mediator, taking into - * account layer order and ability to overlay underlying content. - */ - @ObjCAction - private fun onAccessibilityChanged() { - var isAccessibilityEnabled = - configuration.accessibilitySyncOptions.isGlobalAccessibilityEnabled - layers.withLayers { - it.fastForEachReversed { layer -> - layer.isAccessibilityEnabled = isAccessibilityEnabled - isAccessibilityEnabled = isAccessibilityEnabled && !layer.focusable - } - } - mediator?.isAccessibilityEnabled = isAccessibilityEnabled - } - /** * When there is an ongoing gesture, we need notify redrawer about it. It should unconditionally * unpause CADisplayLink which affects frequency of polling UITouch events on high frequency @@ -436,12 +403,6 @@ internal class ComposeHostingViewController( mediator = null layers.dispose(hasViewAppeared) - - NSNotificationCenter.defaultCenter.removeObserver( - observer = this, - name = UIAccessibilityVoiceOverStatusChanged, - `object` = null - ) } private fun attachLayer(layer: UIKitComposeSceneLayer) { @@ -450,12 +411,10 @@ internal class ComposeHostingViewController( } layers.attach(window, layer, hasViewAppeared) - onAccessibilityChanged() } private fun detachLayer(layer: UIKitComposeSceneLayer) { layers.detach(layer, hasViewAppeared) - onAccessibilityChanged() } @Composable diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt index 2b24b4b8b7525..d7c99617c98b3 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.scene import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalContext import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -41,6 +42,7 @@ import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.layout.OffsetToFocusedRect import androidx.compose.ui.platform.AccessibilityMediator +import androidx.compose.ui.platform.AccessibilitySyncOptions import androidx.compose.ui.platform.CUPERTINO_TOUCH_SLOP import androidx.compose.ui.platform.DefaultInputModeManager import androidx.compose.ui.platform.EmptyViewConfiguration @@ -57,6 +59,7 @@ import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.platform.lerp import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration import androidx.compose.ui.uikit.LocalKeyboardOverlapHeight import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density @@ -109,67 +112,60 @@ import platform.UIKit.UIWindow * * @property rootView The UI container associated with the semantics owner. * @property coroutineContext The coroutine context to use for handling semantics changes. + * @property getAccessibilitySyncOptions A lambda function to retrieve the latest accessibility synchronization options. * @property performEscape A lambda to delegate accessibility escape operation. Returns true if the escape was handled, false otherwise. */ +@OptIn(ExperimentalComposeApi::class) private class SemanticsOwnerListenerImpl( private val rootView: UIView, private val coroutineContext: CoroutineContext, + private val getAccessibilitySyncOptions: () -> AccessibilitySyncOptions, private val convertToAppWindowCGRect: (Rect, UIWindow) -> CValue, private val performEscape: () -> Boolean ) : PlatformContext.SemanticsOwnerListener { - - private var accessibilityMediator: AccessibilityMediator? = null - - var isEnabled: Boolean = false - set(value) { - field = value - accessibilityMediator?.isEnabled = value - } + private var mediator: AccessibilityMediator? = null override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) { - if (accessibilityMediator == null) { - accessibilityMediator = AccessibilityMediator( + if (mediator == null) { + mediator = AccessibilityMediator( rootView, semanticsOwner, coroutineContext, + getAccessibilitySyncOptions, convertToAppWindowCGRect, performEscape - ).also { - it.isEnabled = isEnabled - } + ) } } override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) { - if (accessibilityMediator?.owner == semanticsOwner) { - accessibilityMediator?.dispose() - accessibilityMediator = null + if (mediator?.owner == semanticsOwner) { + mediator?.dispose() + mediator = null } } override fun onSemanticsChange(semanticsOwner: SemanticsOwner) { - if (accessibilityMediator?.owner == semanticsOwner) { - accessibilityMediator?.onSemanticsChange() + if (mediator?.owner == semanticsOwner) { + mediator?.onSemanticsChange() } } override fun onLayoutChange(semanticsOwner: SemanticsOwner, semanticsNodeId: Int) { - if (accessibilityMediator?.owner == semanticsOwner) { - accessibilityMediator?.onLayoutChange(nodeId = semanticsNodeId) + if (mediator?.owner == semanticsOwner) { + mediator?.onLayoutChange(nodeId = semanticsNodeId) } } - val hasInvalidations: Boolean get() = accessibilityMediator?.hasPendingInvalidations ?: false - fun dispose() { - accessibilityMediator?.dispose() - accessibilityMediator = null + mediator?.dispose() + mediator = null } } internal class ComposeSceneMediator( parentView: UIView, - private val onFocusBehavior: OnFocusBehavior, + private val configuration: ComposeUIViewControllerConfiguration, private val focusStack: FocusStack?, private val windowContext: PlatformWindowContext, private val coroutineContext: CoroutineContext, @@ -274,10 +270,14 @@ internal class ComposeSceneMediator( private fun isPointInsideInteractionBounds(point: CValue) = interactionBounds.contains(point.asDpOffset().toOffset(view.density).round()) + @OptIn(ExperimentalComposeApi::class) private val semanticsOwnerListener by lazy { SemanticsOwnerListenerImpl( rootView = view, coroutineContext = coroutineContext, + getAccessibilitySyncOptions = { + configuration.accessibilitySyncOptions + }, convertToAppWindowCGRect = { rect, window -> windowContext.convertWindowRect(rect, window) .toDpRect(Density(window.screen.scale.toFloat())) @@ -292,8 +292,6 @@ internal class ComposeSceneMediator( ) } - var isAccessibilityEnabled by semanticsOwnerListener::isEnabled - private val keyboardManager by lazy { ComposeSceneKeyboardOffsetManager( view = view, @@ -324,11 +322,8 @@ internal class ComposeSceneMediator( } } - val hasInvalidations: Boolean - get() = scene.hasInvalidations() || - keyboardManager.isAnimating || - isLayoutTransitionAnimating || - semanticsOwnerListener.hasInvalidations + val hasInvalidations: Boolean get() = + scene.hasInvalidations() || keyboardManager.isAnimating || isLayoutTransitionAnimating private fun hitTestInteropView(point: CValue, event: UIEvent?): UIView? = point.useContents { @@ -455,7 +450,6 @@ internal class ComposeSceneMediator( } fun render(canvas: Canvas, nanoTime: Long) { - textInputService.flushEditCommandsIfNeeded(force = true) scene.render(canvas, nanoTime) } @@ -478,7 +472,7 @@ internal class ComposeSceneMediator( @Composable private fun FocusAboveKeyboardIfNeeded(content: @Composable () -> Unit) { - if (onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { + if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { OffsetToFocusedRect( insets = PlatformInsets(bottom = keyboardOverlapHeight), getFocusedRect = ::getFocusedRect, diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt index 65b4812f43601..3d0922effcae2 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext -import androidx.compose.ui.uikit.OnFocusBehavior +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration import androidx.compose.ui.uikit.density import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset @@ -51,20 +51,13 @@ internal class UIKitComposeSceneLayer( onGestureEvent: (GestureEvent) -> Unit, private val initDensity: Density, private val initLayoutDirection: LayoutDirection, - private val onAccessibilityChanged: () -> Unit, - onFocusBehavior: OnFocusBehavior, + configuration: ComposeUIViewControllerConfiguration, focusStack: FocusStack?, windowContext: PlatformWindowContext, compositionContext: CompositionContext, ) : ComposeSceneLayer { override var focusable: Boolean = focusStack != null - set(value) { - if (field != value) { - field = value - onAccessibilityChanged() - } - } val view = UIKitComposeSceneLayerView( ::isInsideInteractionBounds, @@ -72,12 +65,12 @@ internal class UIKitComposeSceneLayer( ) private val mediator = ComposeSceneMediator( - parentView = view, - onFocusBehavior = onFocusBehavior, - focusStack = focusStack, - windowContext = windowContext, + view, + configuration, + focusStack, + windowContext, coroutineContext = compositionContext.effectCoroutineContext, - redrawer = metalView.redrawer, + metalView.redrawer, onGestureEvent = onGestureEvent, composeSceneFactory = ::createComposeScene ) @@ -100,8 +93,6 @@ internal class UIKitComposeSceneLayer( val hasInvalidations by mediator::hasInvalidations - var isAccessibilityEnabled by mediator::isAccessibilityEnabled - override var density by mediator::density override var layoutDirection by mediator::layoutDirection diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt index 50ea2f6c90e19..bddbad8e93baa 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt @@ -40,16 +40,12 @@ internal class UIKitComposeSceneLayersHolder( useSeparateRenderThreadWhenPossible: Boolean ) { val hasInvalidations: Boolean - get() = this.layers.any { it.hasInvalidations } + get() = layers.any { it.hasInvalidations } private val layers = mutableListOf() - private val layersCache = CopiedList { - it.addAll(this.layers) + it.addAll(layers) } - - fun withLayers(block: (List) -> Unit) = layersCache.withCopy(block) - private var ongoingGesturesCount = 0 /** @@ -74,12 +70,12 @@ internal class UIKitComposeSceneLayersHolder( ) fun animateSizeTransition(scope: CoroutineScope, duration: Duration) { - if (this.layers.isEmpty()) { + if (layers.isEmpty()) { return } val animations = listOf( windowContext.prepareAndGetSizeTransitionAnimation() - ) + this.layers.map { + ) + layers.map { it.prepareAndGetSizeTransitionAnimation() } @@ -123,8 +119,8 @@ internal class UIKitComposeSceneLayersHolder( // `dispose` is called instead of `close`, because `close` is also used imperatively // to remove the layer from the array based on user interaction. - while (this.layers.isNotEmpty()) { - val layer = this.layers.removeLast() + while (layers.isNotEmpty()) { + val layer = layers.removeLast() if (hasViewAppeared) { layer.sceneWillDisappear() @@ -138,9 +134,9 @@ internal class UIKitComposeSceneLayersHolder( } fun attach(window: UIWindow, layer: UIKitComposeSceneLayer, hasViewAppeared: Boolean) { - val isFirstLayer = this.layers.isEmpty() + val isFirstLayer = layers.isEmpty() - this.layers.add(layer) + layers.add(layer) view.embedSubview(layer.view) view.bringSubviewToFront(metalView) @@ -165,12 +161,12 @@ internal class UIKitComposeSceneLayersHolder( layer.sceneWillDisappear() } - this.layers.remove(layer) + layers.remove(layer) // Intercept the actions UIKitInteropTransaction from the layer val transaction = layer.retrieveInteropTransaction() - if (this.layers.isEmpty()) { + if (layers.isEmpty()) { // It was the last layer, remove the view and executed the actions immediately view.removeFromSuperview() @@ -186,13 +182,13 @@ internal class UIKitComposeSceneLayersHolder( } fun viewDidAppear() { - this.layers.fastForEach { + layers.fastForEach { it.sceneDidAppear() } } fun viewWillDisappear() { - this.layers.fastForEach { + layers.fastForEach { it.sceneWillDisappear() } } @@ -206,7 +202,7 @@ internal class UIKitComposeSceneLayersHolder( val removedLayersTransactionsCopy = removedLayersTransactions.toList() removedLayersTransactions.clear() - val transactions = this.layers.map { + val transactions = layers.map { it.retrieveInteropTransaction() } + removedLayersTransactionsCopy return UIKitInteropTransaction.merge( 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 817e8ffee60f5..dabd6617f8c87 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 @@ -29,8 +29,6 @@ import kotlin.time.DurationUnit import kotlinx.cinterop.CValue import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectMake @@ -90,8 +88,6 @@ internal class IntermediateTextInputUIView( } } - private val mainScope = MainScope() - /** * Callback to handle keyboard presses. The parameter is a [Set] of [UIPress] objects. * Erasure happens due to K/N not supporting Obj-C lightweight generics. @@ -237,18 +233,7 @@ internal class IntermediateTextInputUIView( location.toInt() to length.toInt() } val relativeTextRange = locationRelative until locationRelative + lengthRelative - - // Due to iOS specifics, [setMarkedText] can be called several times in a row. Batching - // helps to avoid text input problems, when Composables use parameters set during - // recomposition instead of the current ones. Example: - // 1. State "1" -> TextField(text = "1") - // 2. setMarkedText "12" -> Not equal to TextField(text = "1") -> State "12" - // 3. setMarkedText "1" -> Equal to TextField(text = "1") -> State remains "12" - // scene.render() - Recomposes TextField - // 4. State "12" -> TextField(text = "12") - Invalid state. Should be TextField(text = "1") - input?.withBatch { - input?.setMarkedText(markedText, relativeTextRange) - } + input?.setMarkedText(markedText, relativeTextRange) } /** @@ -511,14 +496,6 @@ internal class IntermediateTextInputUIView( fun resetOnKeyboardPressesCallback() { onKeyboardPresses = NoOpOnKeyboardPresses } - - private fun IOSSkikoInput.withBatch(update: () -> Unit) { - beginEditBatch() - update() - mainScope.launch { - endEditBatch() - } - } } private class IntermediateTextPosition(val position: Long = 0) : UITextPosition() From 36671d3c77407f85773e25bc7f49df3fba7a0ed3 Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 13:04:50 +0100 Subject: [PATCH 02/11] Update artifactRedirecting to 1.8.0-alpha06 --- gradle.properties | 6 +++--- libraryversions.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index 36bd7198e485e..c913402831b62 100644 --- a/gradle.properties +++ b/gradle.properties @@ -101,11 +101,11 @@ jetbrains.compose.compiler.version=1.5.14.1 # To know which version, should be used, see compose/frameworks/support/libraryversions.toml artifactRedirecting.publication=true # Look for `COMPOSE` in libraryversions.toml -artifactRedirecting.androidx.compose.version=1.8.0-alpha03 +artifactRedirecting.androidx.compose.version=1.8.0-alpha06 # Look for `COMPOSE_MATERIAL3` in libraryversions.toml artifactRedirecting.androidx.compose.material3.version=1.4.0-alpha01 -artifactRedirecting.androidx.compose.foundation.version=1.8.0-alpha03 -artifactRedirecting.androidx.compose.material.version=1.8.0-alpha03 +artifactRedirecting.androidx.compose.foundation.version=1.8.0-alpha06 +artifactRedirecting.androidx.compose.material.version=1.8.0-alpha06 # The latest version is not published yet: https://mvnrepository.com/artifact/androidx.compose.material/material-navigation artifactRedirecting.androidx.compose.material.material-navigation.version=1.7.0-beta01 # Look for `COMPOSE_MATERIAL3_COMMON` in libraryversions.toml diff --git a/libraryversions.toml b/libraryversions.toml index ac98066273c5b..e21057ca1f03f 100644 --- a/libraryversions.toml +++ b/libraryversions.toml @@ -19,7 +19,7 @@ CAMERA_VIEWFINDER = "1.4.0-alpha08" CARDVIEW = "1.1.0-alpha01" CAR_APP = "1.7.0-beta02" COLLECTION = "1.5.0-alpha03" -COMPOSE = "1.8.0-alpha03" +COMPOSE = "1.8.0-alpha06" COMPOSE_COMPILER = "1.5.14" # Update when preparing for a release COMPOSE_MATERIAL3 = "1.4.0-alpha01" COMPOSE_MATERIAL3_ADAPTIVE = "1.1.0-alpha04" From 4e162ae38d020841fa8eaa2f05301134cef8c954 Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 13:34:17 +0100 Subject: [PATCH 03/11] Fix new SnapshotId tests for non-long actual --- .../compose/runtime/snapshots/SnapshotId.kt | 2 + .../runtime/snapshots/SnapshotId.js.kt | 2 + .../runtime/snapshots/SnapshotId.jvm.kt | 2 + .../runtime/snapshots/SnapshotId.native.kt | 2 + .../snapshots/SnapshotDoubleIndexHeapTests.kt | 12 ++-- .../runtime/snapshots/SnapshotIdSetTests.kt | 70 +++++++++---------- .../runtime/snapshots/SnapshotId.wasm.kt | 2 + 7 files changed, 51 insertions(+), 41 deletions(-) diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt index 2d2c9f6b6d645..55e0805254b14 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt @@ -73,3 +73,5 @@ internal expect class SnapshotIdArrayBuilder(array: SnapshotIdArray?) { } internal expect fun Int.toSnapshotId(): SnapshotId + +internal expect fun Long.toSnapshotId(): SnapshotId diff --git a/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt index 997f3caef6770..3dbe19db49f27 100644 --- a/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt +++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt @@ -141,3 +141,5 @@ internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotI internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = doubleArrayOf(id) internal actual fun Int.toSnapshotId(): SnapshotId = toDouble() + +internal actual fun Long.toSnapshotId(): SnapshotId = toDouble() diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt index 4ba29a491ca2e..fe81d402f4d64 100644 --- a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt +++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt @@ -139,3 +139,5 @@ internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotI internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) internal actual fun Int.toSnapshotId(): SnapshotId = toLong() + +internal actual fun Long.toSnapshotId(): SnapshotId = this diff --git a/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt index 4ba29a491ca2e..fe81d402f4d64 100644 --- a/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt @@ -139,3 +139,5 @@ internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotI internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) internal actual fun Int.toSnapshotId(): SnapshotId = toLong() + +internal actual fun Long.toSnapshotId(): SnapshotId = this diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt index b645d39692a73..03d47d25c3b3e 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt @@ -35,9 +35,9 @@ class SnapshotDoubleIndexHeapTests { fun canAddAndRemoveNumbersInSequence() { val heap = SnapshotDoubleIndexHeap() val handles = IntArray(100) - repeat(100) { handles[it] = heap.add(it.toLong()) } + repeat(100) { handles[it] = heap.add(it.toSnapshotId()) } repeat(100) { - assertEquals(it.toLong(), heap.lowestOrDefault(-1)) + assertEquals(it.toSnapshotId(), heap.lowestOrDefault(SnapshotIdInvalidValue)) heap.remove(handles[it]) } assertEquals(0, heap.size) @@ -55,24 +55,24 @@ class SnapshotDoubleIndexHeapTests { if (shouldAdd) { val indexToAdd = random.nextInt(toAdd.size) val value = toAdd[indexToAdd] - val handle = heap.add(value.toLong()) + val handle = heap.add(value.toSnapshotId()) toRemove.add(value to handle) toAdd.removeAt(indexToAdd) } else { val indexToRemove = random.nextInt(toRemove.size) val (value, handle) = toRemove[indexToRemove] - assertTrue(heap.lowestOrDefault(-1) <= value) + assertTrue(heap.lowestOrDefault(SnapshotIdInvalidValue) <= value) heap.remove(handle) toRemove.removeAt(indexToRemove) } heap.validate() for ((value, handle) in toRemove) { - heap.validateHandle(handle, value.toLong()) + heap.validateHandle(handle, value.toSnapshotId()) } val lowestAdded = toRemove.fold(400) { lowest, (value, _) -> if (value < lowest) value else lowest } - assertEquals(lowestAdded, heap.lowestOrDefault(400).toInt()) + assertEquals(lowestAdded, heap.lowestOrDefault(400.toSnapshotId()).toInt()) } } } diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt index c4cf6c2b382e7..48089aa2c2cf7 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt @@ -35,7 +35,7 @@ class SnapshotIdSetTests { @Test fun shouldBeAbleToSetItems() { val times = 10000L - val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) } + val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index.toSnapshotId()) } repeat(times) { set.shouldBe(it, true) } } @@ -45,7 +45,7 @@ class SnapshotIdSetTests { val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - if (index % 2L == 0L) prev.set(index) else prev + if (index % 2L == 0L) prev.set(index.toSnapshotId()) else prev } repeat(times) { set.shouldBe(it, it % 2L == 0L) } @@ -56,7 +56,7 @@ class SnapshotIdSetTests { val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - if (index % 2L == 1L) prev.set(index) else prev + if (index % 2L == 1L) prev.set(index.toSnapshotId()) else prev } repeat(times) { set.shouldBe(it, it % 2L == 1L) } @@ -65,11 +65,11 @@ class SnapshotIdSetTests { @Test fun shouldBeAbleToClearEvens() { val times = 10000L - val allSet = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) } + val allSet = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index.toSnapshotId()) } val set = (0..times).fold(allSet) { prev, index -> - if (index % 2L == 0L) prev.clear(index) else prev + if (index % 2L == 0L) prev.clear(index.toSnapshotId()) else prev } repeat(times - 1) { set.shouldBe(it, it % 2L == 1L) } @@ -79,7 +79,7 @@ class SnapshotIdSetTests { fun shouldBeAbleToCrawlSet() { val times = 10000L val set = - (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.clear(index - 1).set(index) } + (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.clear(index.toSnapshotId() - 1).set(index.toSnapshotId()) } set.shouldBe(times, true) repeat(times - 1) { set.shouldBe(it, false) } @@ -90,7 +90,7 @@ class SnapshotIdSetTests { val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - prev.let { if ((index - 1L) % 33L != 0L) it.clear(index - 1) else it }.set(index) + prev.let { if ((index - 1L) % 33L != 0L) it.clear(index.toSnapshotId() - 1) else it }.set(index.toSnapshotId()) } set.shouldBe(times, true) @@ -98,7 +98,7 @@ class SnapshotIdSetTests { // The multiples of 33 items should now be set repeat(times - 1) { set.shouldBe(it, it % 33L == 0L) } - val newSet = (0 until times).fold(set) { prev, index -> prev.clear(index) } + val newSet = (0 until times).fold(set) { prev, index -> prev.clear(index.toSnapshotId()) } newSet.shouldBe(times, true) @@ -107,19 +107,19 @@ class SnapshotIdSetTests { @Test fun shouldBeAbleToInsertAndRemoveOutOfOptimalRange() { - SnapshotIdSet.EMPTY.set(1000L) - .set(1L) + SnapshotIdSet.EMPTY.set(1000L.toSnapshotId()) + .set(1L.toSnapshotId()) .shouldBe(1000L, true) .shouldBe(1L, true) - .set(10L) + .set(10L.toSnapshotId()) .shouldBe(10L, true) - .set(4L) + .set(4L.toSnapshotId()) .shouldBe(4L, true) - .clear(1L) + .clear(1L.toSnapshotId()) .shouldBe(1L, false) - .clear(4L) + .clear(4L.toSnapshotId()) .shouldBe(4L, false) - .clear(10L) + .clear(10L.toSnapshotId()) .shouldBe(1L, false) .shouldBe(4L, false) .shouldBe(10L, false) @@ -134,14 +134,14 @@ class SnapshotIdSetTests { (0..100L).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = true - prev.set(value.toLong()) + prev.set(value.toSnapshotId()) } val clear = (0..100).fold(set) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = false - prev.clear(value.toLong()) + prev.clear(value.toSnapshotId()) } repeat(1000L) { clear.shouldBe(it, booleans[it.toInt()]) } @@ -155,14 +155,14 @@ class SnapshotIdSetTests { (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = true - prev.set(value.toLong()) + prev.set(value.toSnapshotId()) } val setB = (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = false - prev.set(value.toLong()) + prev.set(value.toSnapshotId()) } val set = setA.andNot(setB) @@ -178,14 +178,14 @@ class SnapshotIdSetTests { (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val setB = (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = false - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val set = setA.andNot(setB) @@ -207,14 +207,14 @@ class SnapshotIdSetTests { (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val setB = (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val set = setA.or(setB) @@ -236,10 +236,10 @@ class SnapshotIdSetTests { (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { values.add(index.toLong()) - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } - values.zip(set).forEach { assertEquals(it.first, it.second) } + values.zip(set).forEach { assertEquals(it.first.toSnapshotId(), it.second) } assertEquals(values.size, set.count()) } @@ -251,20 +251,20 @@ class SnapshotIdSetTests { @Test // Regression b/182822837 fun shouldReportTheCorrectLowest() { - fun test(number: Long) { + fun test(number: SnapshotId) { val set = SnapshotIdSet.EMPTY.set(number) - assertEquals(number, set.lowest(-1)) + assertEquals(number, set.lowest(SnapshotIdInvalidValue)) } - repeat(64) { test(it) } + repeat(64) { test(it.toSnapshotId()) } } @Test fun shouldOverflowGracefully() { - val s = SnapshotIdSet.EMPTY.set(0).set(Long.MAX_VALUE) - assertTrue(s.get(0)) - assertTrue(s.get(Long.MAX_VALUE)) - assertFalse(s.get(1)) + val s = SnapshotIdSet.EMPTY.set(0.toSnapshotId()).set(Long.MAX_VALUE.toSnapshotId()) + assertTrue(s.get(0.toSnapshotId())) + assertTrue(s.get(Long.MAX_VALUE.toSnapshotId())) + assertFalse(s.get(1.toSnapshotId())) } @Test // Regression: b/147836978 @@ -5627,17 +5627,17 @@ class SnapshotIdSetTests { .map { it.split(":").let { it[0].toInt() to it[1].toBoolean() } } operations.fold(SnapshotIdSet.EMPTY) { prev, (value, op) -> assertTrue( - prev.get(value.toLong()) != op, + prev.get(value.toSnapshotId()) != op, "Error on bit $value, expected ${!op}, received $op" ) - val result = if (op) prev.set(value.toLong()) else prev.clear(value.toLong()) + val result = if (op) prev.set(value.toSnapshotId()) else prev.clear(value.toSnapshotId()) result } } } private fun SnapshotIdSet.shouldBe(index: Long, value: Boolean): SnapshotIdSet { - assertEquals(value, get(index), "Bit $index should be $value") + assertEquals(value, get(index.toSnapshotId()), "Bit $index should be $value") return this } diff --git a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt index 4ba29a491ca2e..fe81d402f4d64 100644 --- a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasm.kt @@ -139,3 +139,5 @@ internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotI internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) internal actual fun Int.toSnapshotId(): SnapshotId = toLong() + +internal actual fun Long.toSnapshotId(): SnapshotId = this From 975e5712c144604e0fcb84ba8914aa53f623573b Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 13:37:25 +0100 Subject: [PATCH 04/11] Update desktop API dump --- .../runtime/runtime/api/desktop/runtime.api | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/compose/runtime/runtime/api/desktop/runtime.api b/compose/runtime/runtime/api/desktop/runtime.api index a24f0d18e26a1..c95abe7bf09fe 100644 --- a/compose/runtime/runtime/api/desktop/runtime.api +++ b/compose/runtime/runtime/api/desktop/runtime.api @@ -120,6 +120,12 @@ public abstract interface class androidx/compose/runtime/ComposeNodeLifecycleCal public abstract fun onReuse ()V } +public final class androidx/compose/runtime/ComposeRuntimeFlags { + public static final field $stable I + public static final field INSTANCE Landroidx/compose/runtime/ComposeRuntimeFlags; + public static field isMovingNestedMovableContentEnabled Z +} + public abstract interface class androidx/compose/runtime/Composer { public static final field Companion Landroidx/compose/runtime/Composer$Companion; public abstract fun apply (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)V @@ -302,6 +308,7 @@ public abstract interface class androidx/compose/runtime/ControlledComposition : public abstract fun composeContent (Lkotlin/jvm/functions/Function2;)V public abstract fun delegateInvalidations (Landroidx/compose/runtime/ControlledComposition;ILkotlin/jvm/functions/Function0;)Ljava/lang/Object; public abstract fun disposeUnusedMovableContent (Landroidx/compose/runtime/MovableContentState;)V + public abstract fun getAndSetShouldPauseCallback (Landroidx/compose/runtime/ShouldPauseCallback;)Landroidx/compose/runtime/ShouldPauseCallback; public abstract fun getHasPendingChanges ()Z public abstract fun insertMovableContent (Ljava/util/List;)V public abstract fun invalidateAll ()V @@ -312,7 +319,6 @@ public abstract interface class androidx/compose/runtime/ControlledComposition : public abstract fun recordModificationsOf (Ljava/util/Set;)V public abstract fun recordReadOf (Ljava/lang/Object;)V public abstract fun recordWriteOf (Ljava/lang/Object;)V - public abstract fun setShouldPauseCallback (Lkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; public abstract fun verifyConsistent ()V } @@ -561,7 +567,7 @@ public abstract interface class androidx/compose/runtime/PausedComposition { public abstract fun apply ()V public abstract fun cancel ()V public abstract fun isComplete ()Z - public abstract fun resume (Lkotlin/jvm/functions/Function0;)Z + public abstract fun resume (Landroidx/compose/runtime/ShouldPauseCallback;)Z } public final class androidx/compose/runtime/PrimitiveSnapshotStateKt { @@ -660,6 +666,10 @@ public abstract interface class androidx/compose/runtime/ScopeUpdateScope { public abstract fun updateScope (Lkotlin/jvm/functions/Function2;)V } +public abstract interface class androidx/compose/runtime/ShouldPauseCallback { + public abstract fun shouldPause ()Z +} + public final class androidx/compose/runtime/SkippableUpdater { public static final synthetic fun box-impl (Landroidx/compose/runtime/Composer;)Landroidx/compose/runtime/SkippableUpdater; public static fun constructor-impl (Landroidx/compose/runtime/Composer;)Landroidx/compose/runtime/Composer; @@ -772,6 +782,7 @@ public final class androidx/compose/runtime/Updater { public final class androidx/compose/runtime/collection/MutableVector : java/util/RandomAccess { public static final field $stable I + public field content [Ljava/lang/Object; public fun ([Ljava/lang/Object;I)V public final fun add (ILjava/lang/Object;)V public final fun add (Ljava/lang/Object;)Z @@ -827,10 +838,10 @@ public final class androidx/compose/runtime/collection/MutableVector : java/util public final fun removeAt (I)Ljava/lang/Object; public final fun removeIf (Lkotlin/jvm/functions/Function1;)V public final fun removeRange (II)V + public final fun resizeStorage (I)V public final fun retainAll (Ljava/util/Collection;)Z public final fun reversedAny (Lkotlin/jvm/functions/Function1;)Z public final fun set (ILjava/lang/Object;)Ljava/lang/Object; - public final fun setContent ([Ljava/lang/Object;)V public final fun setSize (I)V public final fun sortWith (Ljava/util/Comparator;)V public final fun sumBy (Lkotlin/jvm/functions/Function1;)I @@ -951,12 +962,14 @@ public abstract class androidx/compose/runtime/snapshots/Snapshot { public static final field Companion Landroidx/compose/runtime/snapshots/Snapshot$Companion; public static final field PreexistingSnapshotId I public synthetic fun (ILandroidx/compose/runtime/snapshots/SnapshotIdSet;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JLandroidx/compose/runtime/snapshots/SnapshotIdSet;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun dispose ()V public final fun enter (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public fun getId ()I public abstract fun getReadObserver ()Lkotlin/jvm/functions/Function1; public abstract fun getReadOnly ()Z public abstract fun getRoot ()Landroidx/compose/runtime/snapshots/Snapshot; + public fun getSnapshotId ()J public abstract fun hasPendingChanges ()Z public fun makeCurrent ()Landroidx/compose/runtime/snapshots/Snapshot; public fun restoreCurrent (Landroidx/compose/runtime/snapshots/Snapshot;)V @@ -1037,6 +1050,21 @@ public final class androidx/compose/runtime/snapshots/SnapshotContextElementKt { public static final fun asContextElement (Landroidx/compose/runtime/snapshots/Snapshot;)Landroidx/compose/runtime/snapshots/SnapshotContextElement; } +public final class androidx/compose/runtime/snapshots/SnapshotId_jvmKt { + public static final field SnapshotIdInvalidValue J + public static final field SnapshotIdMax J + public static final field SnapshotIdSize I + public static final field SnapshotIdZero J + public static final fun compareTo (JI)I + public static final fun compareTo (JJ)I + public static final fun div (JI)J + public static final fun minus (JI)J + public static final fun minus (JJ)J + public static final fun plus (JI)J + public static final fun times (JI)J + public static final fun toInt (J)I +} + public final class androidx/compose/runtime/snapshots/SnapshotKt { public static final fun current (Landroidx/compose/runtime/snapshots/StateRecord;)Landroidx/compose/runtime/snapshots/StateRecord; public static final fun current (Landroidx/compose/runtime/snapshots/StateRecord;Landroidx/compose/runtime/snapshots/Snapshot;)Landroidx/compose/runtime/snapshots/StateRecord; @@ -1172,8 +1200,12 @@ public final class androidx/compose/runtime/snapshots/StateObject$DefaultImpls { public abstract class androidx/compose/runtime/snapshots/StateRecord { public static final field $stable I public fun ()V + public fun (I)V + public fun (J)V public abstract fun assign (Landroidx/compose/runtime/snapshots/StateRecord;)V public abstract fun create ()Landroidx/compose/runtime/snapshots/StateRecord; + public synthetic fun create (I)Landroidx/compose/runtime/snapshots/StateRecord; + public fun create (J)Landroidx/compose/runtime/snapshots/StateRecord; } public final class androidx/compose/runtime/snapshots/tooling/SnapshotInstanceObservers { @@ -1231,6 +1263,12 @@ public abstract interface class androidx/compose/runtime/tooling/CompositionObse public final class androidx/compose/runtime/tooling/CompositionObserverKt { public static final fun observe (Landroidx/compose/runtime/Composition;Landroidx/compose/runtime/tooling/CompositionObserver;)Landroidx/compose/runtime/tooling/CompositionObserverHandle; public static final fun observe (Landroidx/compose/runtime/RecomposeScope;Landroidx/compose/runtime/tooling/RecomposeScopeObserver;)Landroidx/compose/runtime/tooling/CompositionObserverHandle; + public static final fun observe (Landroidx/compose/runtime/Recomposer;Landroidx/compose/runtime/tooling/CompositionRegistrationObserver;)Landroidx/compose/runtime/tooling/CompositionObserverHandle; +} + +public abstract interface class androidx/compose/runtime/tooling/CompositionRegistrationObserver { + public abstract fun onCompositionRegistered (Landroidx/compose/runtime/Recomposer;Landroidx/compose/runtime/Composition;)V + public abstract fun onCompositionUnregistered (Landroidx/compose/runtime/Recomposer;Landroidx/compose/runtime/Composition;)V } public final class androidx/compose/runtime/tooling/InspectionTablesKt { From c57d5339153836344e492eac59f4d65facf7975b Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 14:05:03 +0100 Subject: [PATCH 05/11] Update copy-pasted scroll test --- .../androidx/compose/foundation/Assert.kt | 11 +- .../copyPasteAndroidTests/ScrollableTest.kt | 217 +++++++++++++++++- 2 files changed, 219 insertions(+), 9 deletions(-) diff --git a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/Assert.kt b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/Assert.kt index 10a3f526a9dbd..bb058e5547d92 100644 --- a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/Assert.kt +++ b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/Assert.kt @@ -44,12 +44,8 @@ internal fun AssertThat.isEqualTo(f: Float, eps: Float = 0f) { } } -internal fun AssertThat.isNotEqualTo(i: Int) { - assertNotEquals(i, t) -} - -internal fun AssertThat.isNotEqualTo(i: T) { - assertNotEquals(i, t) +internal fun AssertThat.isNotEqualTo(a: Any?) { + assertNotEquals(a, t) } internal fun AssertThat.isEqualTo(i: Int, d: Int = 0) { @@ -128,6 +124,9 @@ internal fun > AssertThat.contains(vararg items: K) { } } +internal fun AssertThat<*>.isZero() = isEqualTo(0) + +internal fun AssertThat<*>.isNonZero() = isNotEqualTo(0) internal fun AssertThat<*>.isNull() { assertEquals(null, t, message ?: "$t expected to be null") diff --git a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt index 70bfff1a85299..5d823314dd7f1 100644 --- a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt +++ b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt @@ -42,16 +42,17 @@ import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.materialize -import androidx.compose.ui.modifier.ModifierLocalConsumer -import androidx.compose.ui.modifier.ModifierLocalReadScope import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.TraversableNode import androidx.compose.ui.platform.* import androidx.compose.ui.test.* +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.compose.ui.util.fastForEach import kotlin.math.abs +import kotlin.math.absoluteValue import kotlin.test.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -1295,6 +1296,181 @@ class ScrollableTest { } } + @Test + fun scrollable_nestedFling_shouldCancelWhenHitTheBounds() = runSkikoComposeUiTest { + var latestAvailableVelocity = Velocity.Zero + var onPostFlingCalled = false + val connection = + object : NestedScrollConnection { + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + latestAvailableVelocity = available + onPostFlingCalled = true + return super.onPostFling(consumed, available) + } + } + setContent { + Box( + Modifier.scrollable( + state = rememberScrollableState { it }, + orientation = Orientation.Vertical + ) + ) { + Box(Modifier.nestedScroll(connection)) { + Column( + Modifier.testTag("column") + .verticalScroll( + rememberScrollState(with(density) { (5 * 200.dp).roundToPx() }) + ) + ) { + repeat(10) { Box(Modifier.size(200.dp)) } + } + } + } + } + + onNodeWithTag("column").performTouchInput { swipeDown() } + + /** + * Because previously the animation was being completely consumed by the child fling, the + * nested scroll connection in the middle would see a zero post fling velocity, even if the + * child hit the bounds. + */ + runOnIdle { + assertThat(onPostFlingCalled).isTrue() + assertThat(latestAvailableVelocity.y).isNonZero() + } + } + + @Test + fun scrollable_nestedFling_parentShouldFlingWithVelocityLeft() = runSkikoComposeUiTest { + var postFlingCalled = false + var lastPostFlingVelocity = Velocity.Zero + var flingDelta = 0.0f + val fling = + object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + assertThat(initialVelocity).isEqualTo(lastPostFlingVelocity.y) + scrollBy(100f) + return initialVelocity + } + } + val topConnection = + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // accumulate deltas for second fling only + if (source == NestedScrollSource.SideEffect && postFlingCalled) { + flingDelta += available.y + } + return super.onPreScroll(available, source) + } + } + + val middleConnection = + object : NestedScrollConnection { + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + postFlingCalled = true + lastPostFlingVelocity = available + return super.onPostFling(consumed, available) + } + } + val columnState = ScrollState(with(density) { (5 * 200.dp).roundToPx() }) + setContent { + Box( + Modifier.nestedScroll(topConnection) + .scrollable( + flingBehavior = fling, + state = rememberScrollableState { it }, + orientation = Orientation.Vertical + ) + ) { + Column( + Modifier.nestedScroll(middleConnection) + .testTag("column") + .verticalScroll(columnState) + ) { + repeat(10) { Box(Modifier.size(200.dp)) } + } + } + } + + onNodeWithTag("column").performTouchInput { swipeDown() } + + runOnIdle { + assertThat(columnState.value).isZero() // column is at the bounds + assertThat(postFlingCalled) + .isTrue() // we fired a post fling call after the cancellation + assertThat(lastPostFlingVelocity.y) + .isNonZero() // the post child fling velocity was not zero + assertThat(flingDelta).isEqualTo(100f) // the fling delta as propagated correctly + } + } + + @Test + fun scrollable_nestedFling_parentShouldFlingWithVelocityLeft_whenInnerDisappears() = runSkikoComposeUiTest { + var postFlingCalled = false + var postFlingAvailableVelocity = Velocity.Zero + var postFlingConsumedVelocity = Velocity.Zero + var flingDelta by mutableFloatStateOf(0.0f) + var preFlingVelocity = Velocity.Zero + + val topConnection = + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // accumulate deltas for second fling only + if (source == NestedScrollSource.SideEffect) { + flingDelta += available.y + } + return super.onPreScroll(available, source) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + preFlingVelocity = available + return super.onPreFling(available) + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + postFlingCalled = true + postFlingAvailableVelocity = available + postFlingConsumedVelocity = consumed + return super.onPostFling(consumed, available) + } + } + + val columnState = ScrollState(with(density) { (50 * 200.dp).roundToPx() }) + + setContent { + Box(Modifier.nestedScroll(topConnection)) { + if (flingDelta.absoluteValue < 100) { + Column(Modifier.testTag("column").verticalScroll(columnState)) { + repeat(100) { Box(Modifier.size(200.dp)) } + } + } + } + } + + onNodeWithTag("column").performTouchInput { swipeUp() } + waitForIdle() + // removed scrollable + onNodeWithTag("column").assertDoesNotExist() + runOnIdle { + // we fired a post fling call after the disappearance + assertThat(postFlingCalled).isTrue() + + // fling velocity in onPostFling is correctly propagated + assertThat(postFlingConsumedVelocity + postFlingAvailableVelocity) + .isEqualTo(preFlingVelocity) + } + } + @Test @Ignore // TODO: test failing on desktop fun scrollable_bothOrientations_proxiesPostFling() = runSkikoComposeUiTest { @@ -2148,7 +2324,7 @@ class ScrollableTest { Modifier.scrollable( state, Orientation.Vertical, - NoOpOverscrollEffect + null ) ) } @@ -2300,6 +2476,41 @@ class ScrollableTest { } } + @Test + fun onDensityChange_shouldUpdateFlingBehavior() = runSkikoComposeUiTest { + var density by mutableStateOf(density) + var flingDelta = 0f + val fixedSize = 400 + setContent { + CompositionLocalProvider(LocalDensity provides density) { + Box( + Modifier.size(with(density) { fixedSize.toDp() }) + .testTag(scrollableBoxTag) + .scrollable( + state = + rememberScrollableState { + flingDelta += it + it + }, + orientation = Orientation.Vertical + ) + ) + } + } + + onNodeWithTag(scrollableBoxTag).performTouchInput { swipeUp() } + + waitForIdle() + + density = Density(density.density * 2f) + val previousDelta = flingDelta + flingDelta = 0.0f + + onNodeWithTag(scrollableBoxTag).performTouchInput { swipeUp() } + + runOnIdle { assertThat(flingDelta).isNotEqualTo(previousDelta) } + } + private fun SkikoComposeUiTest.setScrollableContent(scrollableModifierFactory: @Composable () -> Modifier) { setContentAndGetScope { Box { From 62b769b118ac04ed15b85444e38c58ce7e378095 Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 14:08:52 +0100 Subject: [PATCH 06/11] Fix deprecated ellipsis usages in desktop tests --- .../DesktopParagraphIntegrationLineHeightStyleTest.kt | 4 +--- .../androidx/compose/ui/text/DesktopParagraphTest.kt | 11 ++++------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphIntegrationLineHeightStyleTest.kt b/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphIntegrationLineHeightStyleTest.kt index 55b43186ab319..c1e9cff7afc81 100644 --- a/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphIntegrationLineHeightStyleTest.kt +++ b/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphIntegrationLineHeightStyleTest.kt @@ -964,7 +964,6 @@ class DesktopParagraphIntegrationLineHeightStyleTest { text: String = "", style: TextStyle? = null, maxLines: Int = Int.MAX_VALUE, - ellipsis: Boolean = false, spanStyles: List> = listOf(), width: Float = Float.MAX_VALUE ): Paragraph { @@ -977,10 +976,9 @@ class DesktopParagraphIntegrationLineHeightStyleTest { lineHeight = lineHeight, ).merge(style), maxLines = maxLines, - ellipsis = ellipsis, constraints = Constraints(maxWidth = width.ceilToInt()), density = defaultDensity, - fontFamilyResolver = fontFamilyResolver + fontFamilyResolver = fontFamilyResolver, ) } diff --git a/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphTest.kt b/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphTest.kt index cb26b7902ca57..877561e51d53a 100644 --- a/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphTest.kt +++ b/compose/ui/ui-text/src/desktopTest/kotlin/androidx/compose/ui/text/DesktopParagraphTest.kt @@ -20,10 +20,10 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.font.createFontFamilyResolver import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.createFontFamilyResolver import androidx.compose.ui.text.platform.Font import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign @@ -33,11 +33,11 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp import com.google.common.truth.Truth +import kotlin.math.roundToInt import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 -import kotlin.math.roundToInt @RunWith(JUnit4::class) class DesktopParagraphTest { @@ -687,7 +687,6 @@ class DesktopParagraphTest { text: String = "", style: TextStyle? = null, maxLines: Int = Int.MAX_VALUE, - ellipsis: Boolean = false, spanStyles: List> = listOf(), density: Density? = null, width: Float = 2000f @@ -699,7 +698,6 @@ class DesktopParagraphTest { fontFamily = fontFamilyMeasureFont ).merge(style), maxLines = maxLines, - ellipsis = ellipsis, constraints = Constraints(maxWidth = width.ceilToInt()), density = density ?: defaultDensity, fontFamilyResolver = fontFamilyResolver @@ -712,6 +710,7 @@ class DesktopParagraphTest { spanStyles: List> = listOf(), density: Density? = null ): ParagraphIntrinsics { + // TODO https://youtrack.jetbrains.com/issue/CMP-7151 return ParagraphIntrinsics( text = text, spanStyles = spanStyles, @@ -726,14 +725,12 @@ class DesktopParagraphTest { private fun simpleParagraph( intrinsics: ParagraphIntrinsics, maxLines: Int = Int.MAX_VALUE, - ellipsis: Boolean = false, width: Float = 2000f ): Paragraph { return Paragraph( paragraphIntrinsics = intrinsics, - maxLines = maxLines, - ellipsis = ellipsis, constraints = Constraints(maxWidth = width.ceilToInt()), + maxLines = maxLines, ) } } From 0ace93bed220a0d1d84a5dbb63c110a8a20158fb Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 14:26:37 +0100 Subject: [PATCH 07/11] Update Owner usages in tests --- .../compose/ui/input/pointer/PointerIconTest.kt | 8 ++++---- .../androidx/compose/ui/node/DepthSortedSetTest.kt | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt b/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt index 22b108f7c7fff..4d73757d113ae 100644 --- a/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt +++ b/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt @@ -53,13 +53,13 @@ class PointerIconTest { private val iconService = object : PointerIconService { private var current: PointerIcon = PointerIcon.Default - override fun getIcon(): PointerIcon { - return current - } - + override fun getIcon(): PointerIcon = current override fun setIcon(value: PointerIcon?) { current = value ?: PointerIcon.Default } + + override fun getStylusHoverIcon(): PointerIcon? = null + override fun setStylusHoverIcon(value: PointerIcon?) {} } @Test diff --git a/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/node/DepthSortedSetTest.kt b/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/node/DepthSortedSetTest.kt index 360c1e396ea50..8bf47ee09f63e 100644 --- a/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/node/DepthSortedSetTest.kt +++ b/compose/ui/ui/src/skikoTest/kotlin/androidx/compose/ui/node/DepthSortedSetTest.kt @@ -16,18 +16,18 @@ package androidx.compose.ui.node +import androidx.collection.IntObjectMap import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.SemanticAutofill import androidx.compose.ui.draganddrop.DragAndDropManager import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusOwner import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.GraphicsContext -import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.input.InputModeManager @@ -40,10 +40,10 @@ import androidx.compose.ui.platform.PlatformTextInputSessionScope import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.spatial.RectManager import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.runSkikoComposeUiTest -import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextInputService @@ -190,6 +190,7 @@ class DepthSortedSetTest { // TODO Is there a way to mock it in K/N? internal class DepthTestOwner : Owner { + override val layoutNodes: IntObjectMap get() = throw IllegalStateException() override val rootForTest: RootForTest get() = throw IllegalStateException() override val hapticFeedBack: HapticFeedback get() = throw IllegalStateException() override val inputModeManager: InputModeManager get() = throw IllegalStateException() @@ -201,7 +202,7 @@ class DepthSortedSetTest { override val autofillTree: AutofillTree get() = throw IllegalStateException() @ExperimentalComposeUiApi override val autofill: Autofill? get() = throw IllegalStateException() - override val semanticAutofill: SemanticAutofill? get() = throw IllegalStateException() + override val autofillManager: AutofillManager? get() = throw IllegalStateException() override val density: Density get() = throw IllegalStateException() override val textInputService: TextInputService get() = throw IllegalStateException() override val softwareKeyboardController get() = throw IllegalStateException() @@ -212,6 +213,7 @@ class DepthSortedSetTest { override fun localToScreen(localPosition: Offset): Offset = throw IllegalStateException() override val dragAndDropManager: DragAndDropManager get() = throw IllegalStateException() override val pointerIconService: PointerIconService get() = throw IllegalStateException() + override val semanticsOwner: SemanticsOwner get() = throw IllegalStateException() override val focusOwner: FocusOwner get() = throw IllegalStateException() override val windowInfo: WindowInfo get() = throw IllegalStateException() override val rectManager: RectManager get() = throw IllegalStateException() From 8b379422e7f606bc5ce28ce4dacac05612244fae Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 16:33:03 +0100 Subject: [PATCH 08/11] Temporary ignore failed test --- .../compose/foundation/copyPasteAndroidTests/ScrollableTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt index 5d823314dd7f1..e3b1d345413d6 100644 --- a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt +++ b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/ScrollableTest.kt @@ -2477,6 +2477,7 @@ class ScrollableTest { } @Test + @Ignore // TODO(https://youtrack.jetbrains.com/issue/CMP-7220) Fails on iOS fun onDensityChange_shouldUpdateFlingBehavior() = runSkikoComposeUiTest { var density by mutableStateOf(density) var flingDelta = 0f From 809e206c24d1775f1978c9c7528eca326afcffd9 Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 17:18:32 +0100 Subject: [PATCH 09/11] Use org.jetbrains.skiko.MainUIDispatcher for postDelayed on Desktop --- .../androidx/compose/ui/Actuals.desktop.kt | 23 +++++++++++++++++++ .../androidx/compose/ui/Actuals.jsNative.kt | 5 ++++ .../androidx/compose/ui/Actuals.skiko.kt | 7 ++++-- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/Actuals.desktop.kt diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/Actuals.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/Actuals.desktop.kt new file mode 100644 index 0000000000000..20fdc823323bb --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/Actuals.desktop.kt @@ -0,0 +1,23 @@ +/* + * 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. + */ + +package androidx.compose.ui + +import kotlin.coroutines.CoroutineContext +import org.jetbrains.skiko.MainUIDispatcher + +internal actual val PostDelayedDispatcher: CoroutineContext + get() = MainUIDispatcher diff --git a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt index a67761d861f9f..ec69dc8294219 100644 --- a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt +++ b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/Actuals.jsNative.kt @@ -18,7 +18,9 @@ package androidx.compose.ui import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.InspectorInfo +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.Dispatchers internal actual fun classKeyForObject(a: Any): Any { return a::class @@ -35,3 +37,6 @@ internal actual fun InspectorInfo.tryPopulateReflectively( internal actual abstract class PlatformOptimizedCancellationException actual constructor( message: String? ) : CancellationException(message) + +internal actual val PostDelayedDispatcher: CoroutineContext + get() = Dispatchers.Main diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt index 22bdf890fc3a2..6d9c05cdf37ff 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/Actuals.skiko.kt @@ -16,6 +16,7 @@ package androidx.compose.ui +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -23,10 +24,12 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +internal expect val PostDelayedDispatcher: CoroutineContext + @OptIn(DelicateCoroutinesApi::class) internal actual fun postDelayed(delayMillis: Long, block: () -> Unit): Any { - // TODO https://youtrack.jetbrains.com/issue/CMP-7153/Remove-usage-of-the-main-thread-for-rect-tracking - return GlobalScope.launch(Dispatchers.Main) { + // TODO https://youtrack.jetbrains.com/issue/CMP-7153 + return GlobalScope.launch(PostDelayedDispatcher) { delay(delayMillis) block() } From de3f25a3974a86382fc669ce0c5a23015d41202d Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 17:45:12 +0100 Subject: [PATCH 10/11] Temporary skip unsupported annotations --- .../compose/ui/text/platform/ParagraphBuilder.skiko.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt index 578d190234b6b..d932dacd24cd1 100644 --- a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt +++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/platform/ParagraphBuilder.skiko.kt @@ -428,8 +428,8 @@ internal class ParagraphBuilder( val cuts = mutableListOf() for (annotation in annotations) { - // TODO https://youtrack.jetbrains.com/issue/CMP-7151/Support-ParagraphIntrinsics-with-annotations - annotation.item as SpanStyle + // TODO https://youtrack.jetbrains.com/issue/CMP-7151 + if (annotation.item !is SpanStyle) continue cuts.add(Cut.StyleAdd(annotation.start, annotation.item)) cuts.add(Cut.StyleRemove(annotation.end, annotation.item)) From 950f761ba12cbcb1c3cbb6a696188e68cccea5f4 Mon Sep 17 00:00:00 2001 From: Ivan Matkov Date: Mon, 9 Dec 2024 18:07:12 +0100 Subject: [PATCH 11/11] Restore focusRequester in Slider --- .../commonMain/kotlin/androidx/compose/material/Slider.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt index aa589909f7b3b..7f35d4cb65efb 100644 --- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt +++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt @@ -70,6 +70,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.Color @@ -170,6 +172,7 @@ fun Slider( val onValueChangeFinishedState = rememberUpdatedState(onValueChangeFinished) val tickFractions = remember(steps) { stepsToTickFractions(steps) } + val focusRequester = remember { FocusRequester() } BoxWithConstraints( modifier .minimumInteractiveComponentSize() @@ -182,6 +185,7 @@ fun Slider( valueRange, steps ) + .focusRequester(focusRequester) .focusable(enabled, interactionSource) .slideOnKeyEvents( enabled, @@ -229,6 +233,7 @@ fun Slider( rememberUpdatedState<(Float) -> Unit> { velocity: Float -> val current = rawOffset.floatValue val target = snapValueToTick(current, tickFractions, minPx, maxPx) + focusRequester.requestFocus() if (current != target) { scope.launch { animateToTarget(draggableState, current, target, velocity)