diff --git a/.mapping.json b/.mapping.json index 5ad6be593..b1b6a0fbc 100644 --- a/.mapping.json +++ b/.mapping.json @@ -1670,6 +1670,7 @@ "client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/OutsideActions.kt":"divkit/public/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/OutsideActions.kt", "client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/UiTestDivActionHandler.kt":"divkit/public/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/UiTestDivActionHandler.kt", "client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/Utils.kt":"divkit/public/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/Utils.kt", + "client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewCoordinatesHelper.kt":"divkit/public/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewCoordinatesHelper.kt", "client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewInteractionExt.kt":"divkit/public/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewInteractionExt.kt", "client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewPagerActions.kt":"divkit/public/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewPagerActions.kt", "client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/view/TextView.kt":"divkit/public/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/view/TextView.kt", diff --git a/client/android/div/src/main/java/com/yandex/div/core/tooltip/DivTooltipController.kt b/client/android/div/src/main/java/com/yandex/div/core/tooltip/DivTooltipController.kt index e3d26747f..5509ad607 100644 --- a/client/android/div/src/main/java/com/yandex/div/core/tooltip/DivTooltipController.kt +++ b/client/android/div/src/main/java/com/yandex/div/core/tooltip/DivTooltipController.kt @@ -17,6 +17,7 @@ import androidx.annotation.VisibleForTesting import androidx.core.os.postDelayed import androidx.core.view.children import com.yandex.div.R +import com.yandex.div.core.DivActionHandler import com.yandex.div.core.DivPreloader import com.yandex.div.core.DivTooltipRestrictor import com.yandex.div.core.actions.logError @@ -36,6 +37,7 @@ import com.yandex.div.core.view2.errors.ErrorCollectors import com.yandex.div.internal.Assert import com.yandex.div.json.expressions.ExpressionResolver import com.yandex.div2.Div +import com.yandex.div2.DivAction import com.yandex.div2.DivTooltip import com.yandex.div2.DivTooltipMode import javax.inject.Inject @@ -211,7 +213,9 @@ internal class DivTooltipController @VisibleForTesting constructor( this, tooltipView, divTooltip.isModal(), - divTooltip.shouldDismissByOutsideTouch(resolver) + isOutsideTouchable, + divTooltip.tapOutsideActions, + context ) ) setupAnimation(divTooltip, resolver) @@ -319,7 +323,9 @@ private class PopupWindowTouchListener( private val popupWindow: PopupWindow, private val tooltipView: View, private val isModal: Boolean, - private val shouldDismissByOutsideTouch: Boolean + private val shouldDismissByOutsideTouch: Boolean, + private val tapOutsideActions: List?, + private val bindingContext: BindingContext ) : View.OnTouchListener { private val hitRect = Rect() @@ -330,7 +336,24 @@ private class PopupWindowTouchListener( hitRect.contains(event.x.toInt(), event.y.toInt()) -> false else -> { - if (shouldDismissByOutsideTouch) popupWindow.dismiss() + if (event.action == MotionEvent.ACTION_UP) { + tapOutsideActions?.let { actions -> + val resolver = bindingContext.expressionResolver + val divView = bindingContext.divView + actions.filter { it.isEnabled.evaluate(resolver) }.forEach { action -> + divView.div2Component.actionHandler.handleActionWithReason( + action, + divView, + resolver, + DivActionHandler.DivActionReason.CLICK + ) + } + } + + if (shouldDismissByOutsideTouch) { + popupWindow.dismiss() + } + } isModal } } diff --git a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivTooltipTest.kt b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivTooltipTest.kt index ddf63180f..a8e6369dd 100644 --- a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivTooltipTest.kt +++ b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivTooltipTest.kt @@ -57,6 +57,68 @@ class DivTooltipTest { } } + @Test + fun tooltipIsNotDismissedWhenCloseByTapOutsideIsFalse() { + tooltipDiv { + showNonCloseByTapOutsideTooltip() + + assert { + tooltipShown() + } + + clickOnTooltipWrapper() + assert { + tooltipShown() + } + + closeTooltip() + + assert { + noTooltipsDisplayed() + } + } + } + + @Test + fun tooltipTapOutsideActionsAreExecuted() { + tooltipDiv { + assert { + outsideActionsCalledIsFalse() + } + + showTooltipWithTapOutsideActions() + clickOnTooltipWrapper() + + assert { + outsideActionsCalledIsTrue() + noTooltipsDisplayed() + } + } + } + + @Test + fun nonModalTooltipAllowsClickingUnderlyingElements() { + tooltipDiv { + assert { + nonModalButtonClickedIsFalse() + } + + val buttonPosition = getUnderlyingButtonPosition() + showNonModalTooltip() + + assert { + tooltipShown() + } + + clickAtPoint(buttonPosition) + + assert { + nonModalButtonClickedIsTrue() + noTooltipsDisplayed() + } + } + } + private fun checkTooltip(position: Position) = step("Checking tooltip with position: $position") { tooltipDiv { showTooltip(position) diff --git a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/steps/DivTooltipSteps.kt b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/steps/DivTooltipSteps.kt index f147d667a..1d3143c89 100644 --- a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/steps/DivTooltipSteps.kt +++ b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/steps/DivTooltipSteps.kt @@ -1,11 +1,16 @@ package com.yandex.div.steps +import android.graphics.Point +import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withText +import com.yandex.div.utils.ViewCoordinatesHelper.clickAtPosition +import com.yandex.div.utils.ViewCoordinatesHelper.getViewCenterCoordinates import com.yandex.div.utils.clickOutside import com.yandex.div.utils.swipeLeftOutside import com.yandex.div2.DivTooltip @@ -32,6 +37,30 @@ internal class DivTooltipSteps { fun clickTooltip(): Unit = step("click on tooltip") { tooltip().perform(click()) } + + fun closeTooltip(): Unit = step("close tooltip") { + Espresso.pressBack() + } + + fun showNonCloseByTapOutsideTooltip(): Unit = step("Show tooltip with close_by_tap_outside: false") { + onView(withText("tooltip with close_by_tap_outside: false")).perform(click()) + } + + fun showTooltipWithTapOutsideActions(): Unit = step("Show tooltip with tap_outside_actions") { + onView(withText("tooltip with tap_outside_actions")).perform(click()) + } + + fun showNonModalTooltip(): Unit = step("Show non-modal tooltip") { + onView(withText("tooltip mode = non_modal")).perform(click()) + } + + fun clickAtPoint(point: Point): Unit = step("Click at point (${point.x}, ${point.y})") { + onView(isRoot()).perform(clickAtPosition(point.x, point.y)) + } + + fun getUnderlyingButtonPosition(): Point = step("Get position of the underlying button") { + return@step getViewCenterCoordinates(withText("non modal test")) + } fun assert(f: DivTooltipAssertions.() -> Unit) = f(DivTooltipAssertions()) } @@ -53,6 +82,22 @@ internal class DivTooltipAssertions { fun disappearActionHandled(): Unit = step("Check disappear action is handled") { onView(withText("disappear_work")).check(matches(isDisplayed())) } + + fun outsideActionsCalledIsFalse(): Unit = step("Check outside actions called is false") { + onView(withText("Outside actions called: false")).check(matches(isDisplayed())) + } + + fun outsideActionsCalledIsTrue(): Unit = step("Check outside actions called is true") { + onView(withText("Outside actions called: true")).check(matches(isDisplayed())) + } + + fun nonModalButtonClickedIsFalse(): Unit = step("Check non modal button clicked is false") { + onView(withText("Non modal button clicked: false")).check(matches(isDisplayed())) + } + + fun nonModalButtonClickedIsTrue(): Unit = step("Check non modal button clicked is true") { + onView(withText("Non modal button clicked: true")).check(matches(isDisplayed())) + } } private fun tooltip() = onView(withText("tooltip_text")).inRoot(isPlatformPopup()) diff --git a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewCoordinatesHelper.kt b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewCoordinatesHelper.kt new file mode 100644 index 000000000..cbad05636 --- /dev/null +++ b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/utils/ViewCoordinatesHelper.kt @@ -0,0 +1,87 @@ +package com.yandex.div.utils + +import android.graphics.Point +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import org.hamcrest.Matcher + +/** + * Helper functions for working with view coordinates in UI tests. + */ +object ViewCoordinatesHelper { + + /** + * Gets the center coordinates of a view. + * @param matcher The matcher to find the view + * @return Point with x, y coordinates of the center of the view + */ + fun getViewCenterCoordinates(matcher: Matcher): Point { + val coordinates = IntArray(2) + + onView(matcher).perform(object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayed() + } + + override fun getDescription(): String { + return "Get view center coordinates" + } + + override fun perform(uiController: UiController, view: View) { + view.getLocationOnScreen(coordinates) + coordinates[0] += view.width / 2 + coordinates[1] += view.height / 2 + } + }) + + return Point(coordinates[0], coordinates[1]) + } + + /** + * Creates a ViewAction that clicks at specific coordinates on the screen. + * @param x The x coordinate to click at + * @param y The y coordinate to click at + * @return A ViewAction that performs the click + */ + fun clickAtPosition(x: Int, y: Int): ViewAction { + return object : ViewAction { + override fun getConstraints(): Matcher { + return isRoot() + } + + override fun getDescription(): String { + return "Click at position ($x, $y)" + } + + override fun perform(uiController: UiController, view: View) { + val downTime = SystemClock.uptimeMillis() + val eventTime = SystemClock.uptimeMillis() + + val downEvent = MotionEvent.obtain( + downTime, eventTime, MotionEvent.ACTION_DOWN, + x.toFloat(), y.toFloat(), 0 + ) + + val upEvent = MotionEvent.obtain( + downTime, eventTime + 100, MotionEvent.ACTION_UP, + x.toFloat(), y.toFloat(), 0 + ) + + uiController.injectMotionEvent(downEvent) + uiController.loopMainThreadForAtLeast(100) // Wait a bit between events + uiController.injectMotionEvent(upEvent) + + uiController.loopMainThreadForAtLeast(300) + + downEvent.recycle() + upEvent.recycle() + } + } + } +} diff --git a/schema/div-tooltip.json b/schema/div-tooltip.json index 852e85d17..1d10d0cc8 100644 --- a/schema/div-tooltip.json +++ b/schema/div-tooltip.json @@ -74,6 +74,7 @@ }, "$description": "translations.json#/div_tooltip_tap_outside_actions", "platforms": [ + "android", "ios", "web" ] diff --git a/test_data/ui_test_data/tooltips/div_tooltips.json b/test_data/ui_test_data/tooltips/div_tooltips.json index 92dc7c889..974f0f52a 100644 --- a/test_data/ui_test_data/tooltips/div_tooltips.json +++ b/test_data/ui_test_data/tooltips/div_tooltips.json @@ -1,5 +1,17 @@ { "log_id": "snapshot_test_card", + "variables": [ + { + "type": "string", + "name": "outside_actions_called", + "value": "false" + }, + { + "type": "string", + "name": "non_modal_button_clicked", + "value": "false" + } + ], "states": [ { "state_id": 0, @@ -156,6 +168,227 @@ } } ] + }, + { + "type": "separator" + }, + { + "type": "button", + "text": "tooltip with close_by_tap_outside: false", + "margins": { + "left": 10, + "top": 10, + "right": 10, + "bottom": 10 + }, + "tooltips": [ + { + "id": "tooltip_1", + "position": "bottom", + "gravity": "bottom", + "close_by_tap_outside": false, + "duration": 0, + "offset": { + "x": { + "value": 0 + }, + "y": { + "value": 4 + } + }, + "div": { + "type": "text", + "text": "tooltip_text", + "text_color": "#2C3E50", + "background": [ + { + "type": "solid", + "color": "#799eb7" + } + ], + "paddings": { + "left": 16, + "top": 16, + "right": 16, + "bottom": 16 + }, + "border": { + "corner_radius": 6 + } + } + } + ], + "actions": [ + { + "log_id": "show_tooltip", + "url": "div-action://show_tooltip?id=tooltip_1&multiple=true" + } + ] + }, + { + "type": "button", + "text": "tooltip with tap_outside_actions", + "margins": { + "left": 10, + "top": 10, + "right": 10, + "bottom": 10 + }, + "tooltips": [ + { + "id": "tooltip_2", + "position": "bottom", + "gravity": "bottom", + "tap_outside_actions": [ + { + "log_id": "outside_actions", + "typed": { + "type": "set_variable", + "variable_name": "outside_actions_called", + "value": { + "type": "string", + "value": "true" + } + } + } + ], + "duration": 0, + "offset": { + "x": { + "value": 0 + }, + "y": { + "value": 4 + } + }, + "div": { + "type": "text", + "text": "tooltip_text", + "text_color": "#2C3E50", + "background": [ + { + "type": "solid", + "color": "#799eb7" + } + ], + "paddings": { + "left": 16, + "top": 16, + "right": 16, + "bottom": 16 + }, + "border": { + "corner_radius": 6 + } + } + } + ], + "actions": [ + { + "log_id": "show_tooltip", + "url": "div-action://show_tooltip?id=tooltip_2&multiple=true" + } + ] + }, + { + "id": "tooltip_2_result", + "type": "text", + "text": "Outside actions called: @{outside_actions_called}", + "margins": { + "left": 10, + "top": 10, + "right": 10, + "bottom": 10 + }, + "text_color": "@{(outside_actions_called == 'true') ? '#00FF00' : '#000000'}" + }, + { + "type": "container", + "orientation": "horizontal", + "margins": { + "left": 10, + "top": 10, + "right": 10, + "bottom": 10 + }, + "items": [ + { + "type": "button", + "text": "tooltip mode = non_modal", + "gravity": "center", + "margins": { + "right": 10 + }, + "tooltips": [ + { + "id": "tooltip_3", + "position": "top", + "gravity": "top", + "duration": 0, + "mode": { + "type": "non_modal" + }, + "close_by_tap_outside": true, + "div": { + "type": "text", + "text": "tooltip_text", + "text_color": "#2C3E50", + "background": [ + { + "type": "solid", + "color": "#799eb7" + } + ], + "paddings": { + "left": 16, + "top": 16, + "right": 16, + "bottom": 16 + }, + "border": { + "corner_radius": 6 + } + } + } + ], + "actions": [ + { + "log_id": "show_tooltip", + "url": "div-action://show_tooltip?id=tooltip_3&multiple=true" + } + ] + }, + { + "type": "button", + "text": "non modal test", + "gravity": "center", + "actions": [ + { + "log_id": "non_modal_test", + "typed": { + "type": "set_variable", + "variable_name": "non_modal_button_clicked", + "value": { + "type": "string", + "value": "true" + } + } + } + ] + } + ] + }, + { + "id": "non_modal_result", + "type": "text", + "text": "Non modal button clicked: @{non_modal_button_clicked}", + "margins": { + "left": 10, + "top": 10, + "right": 10, + "bottom": 10 + }, + "text_color": "@{(non_modal_button_clicked == 'true') ? '#00FF00' : '#000000'}" } ] }