diff --git a/BLOCK_OUTSIDE_CLICKS.md b/BLOCK_OUTSIDE_CLICKS.md new file mode 100644 index 00000000..6d36b089 --- /dev/null +++ b/BLOCK_OUTSIDE_CLICKS.md @@ -0,0 +1,74 @@ +# Block Outside Clicks Feature + +This document describes the new "Block Outside Clicks" feature that makes app-level floating windows behave like Dialog, preventing interactions with content behind the floating window. + +## Usage + +### Builder Pattern +```kotlin +// Create a floating window with block outside clicks enabled +private val modalFx by createFx { + setLayout(R.layout.item_floating) + setBlockOutsideClicks(true) // Enable blocking outside clicks + setGravity(FxGravity.CENTER) + build().toControl(this@YourActivity) +} + +// Show the modal floating window +modalFx.show() +``` + +### Runtime Configuration +```kotlin +// Enable blocking outside clicks at runtime +floatingWindowControl.configControl.setBlockOutsideClicks(true) + +// Disable blocking outside clicks at runtime +floatingWindowControl.configControl.setBlockOutsideClicks(false) +``` + +### Global FloatingX Installation +```kotlin +FloatingX.install { + setContext(context) + setLayout(R.layout.your_floating_layout) + setScopeType(FxScopeType.APP) // Only works with APP scope + setBlockOutsideClicks(true) +}.show() +``` + +## Features + +- **Dialog-like behavior**: When enabled, the floating window prevents touches from reaching the content behind it +- **Touch area detection**: Only touches outside the floating window bounds are blocked +- **Runtime toggle**: Can be enabled/disabled at runtime using the config control +- **APP-level only**: This feature only works with app-level floating windows (`FxScopeType.APP`), not system-level windows +- **Efficient implementation**: Uses touch interception at the DecorView level for optimal performance + +## Important Notes + +1. **APP-level only**: This feature only works with app-level floating windows. System floating windows cannot block touches to other applications. + +2. **Proper cleanup**: The touch interception is automatically cleaned up when the floating window is hidden or destroyed. + +3. **Performance**: The implementation is lightweight and doesn't create additional overlay views. + +## Demo + +Check out the `BlockOutsideClicksTestActivity` in the demo app to see this feature in action. The demo shows: + +- A background with clickable buttons +- A modal floating window that blocks clicks to the background +- A normal floating window for comparison +- Toggle functionality to enable/disable the blocking behavior + +## Example Output + +When the feature is enabled: +- Clicking on the floating window works normally +- Clicking anywhere else on the screen is blocked and doesn't reach the Activity's views +- The floating window behaves like a modal dialog + +When the feature is disabled: +- Normal floating window behavior +- Background content remains interactive \ No newline at end of file diff --git a/README.md b/README.md index 85e88f6e..6dc1c4ba 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - 支持 **JetPack Compose** - 支持 **浮窗半隐藏模式** +- 支持 **阻挡外部点击**,类似Dialog的模态行为; - 支持 **自定义隐藏显示动画**; - 支持 **多指触摸**,精准决策触摸手势; - 支持 自定义是否保存历史位置及还原; diff --git a/README_EN.md b/README_EN.md index 7b938618..1b120f13 100644 --- a/README_EN.md +++ b/README_EN.md @@ -19,6 +19,7 @@ - Supports **JetPack Compose** - Supports **semi-hidden floating window mode** +- Supports **blocking outside clicks**, similar to Dialog modal behavior; - Supports **custom hide/show animations**; - Supports **multi-touch**, precise touch gesture recognition; - Supports custom history position saving and restoration; diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 89a0422d..2b29a527 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,9 @@ android:configChanges="keyboard|orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|locale|navigation|fontScale|mcc|mnc|uiMode" /> + (R.id.cardItemFx).setCardBackgroundColor(Color.RED) + } + } + addItemView("关闭阻挡外部点击") { + activityFx.configControl.setBlockOutsideClicks(false) + } addItemView("进入测试页面") { TestActivity::class.java.start(this@MainActivity) } + addItemView("进入阻挡外部点击测试页面") { + com.petterp.floatingx.app.test.BlockOutsideClicksTestActivity::class.java.start(this@MainActivity) + } addItemView("进入system浮窗测试页面") { SystemActivity::class.java.start(this@MainActivity) } diff --git a/app/src/main/java/com/petterp/floatingx/app/test/BlockOutsideClicksTestActivity.kt b/app/src/main/java/com/petterp/floatingx/app/test/BlockOutsideClicksTestActivity.kt new file mode 100644 index 00000000..c8b5c74b --- /dev/null +++ b/app/src/main/java/com/petterp/floatingx/app/test/BlockOutsideClicksTestActivity.kt @@ -0,0 +1,97 @@ +package com.petterp.floatingx.app.test + +import android.graphics.Color +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.petterp.floatingx.app.R +import com.petterp.floatingx.app.addItemView +import com.petterp.floatingx.app.addLinearLayout +import com.petterp.floatingx.app.addNestedScrollView +import com.petterp.floatingx.app.createLinearLayoutToParent +import com.petterp.floatingx.app.simple.FxAnimationImpl +import com.petterp.floatingx.assist.FxGravity +import com.petterp.floatingx.util.createFx + +/** + * Test activity for block outside clicks functionality + * @author petterp + */ +class BlockOutsideClicksTestActivity : AppCompatActivity() { + + private var isModalBlocking = true // Track the current state + + // Create a floating window with block outside clicks enabled by default + private val modalFx by createFx { + setLayout(R.layout.item_floating) + setBlockOutsideClicks(true) + setGravity(FxGravity.CENTER) + setAnimationImpl(FxAnimationImpl()) + setEnableAnimation(true) + setEnableLog(true, "modal_fx") + build().toControl(this@BlockOutsideClicksTestActivity) + } + + // Create a normal floating window for comparison + private val normalFx by createFx { + setLayout(R.layout.item_floating) + setGravity(FxGravity.TOP_OR_LEFT) + setOffsetXY(50f, 100f) + setEnableLog(true, "normal_fx") + build().toControl(this@BlockOutsideClicksTestActivity) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + createLinearLayoutToParent { + setBackgroundColor(Color.LTGRAY) + addNestedScrollView { + addLinearLayout { + addItemView("这是一个可点击的按钮") { + Toast.makeText( + this@BlockOutsideClicksTestActivity, + "背景按钮被点击了!", + Toast.LENGTH_SHORT + ).show() + } + addItemView("显示模态浮窗(阻挡外部点击)") { + modalFx.show() + modalFx.updateViewContent { holder -> + holder.setText(R.id.tvItemFx, "模态") + } + } + addItemView("显示普通浮窗") { + normalFx.show() + normalFx.updateViewContent { holder -> + holder.setText(R.id.tvItemFx, "普通") + } + } + addItemView("隐藏模态浮窗") { + modalFx.hide() + } + addItemView("隐藏普通浮窗") { + normalFx.hide() + } + addItemView("切换模态浮窗的阻挡模式") { + // Toggle the block outside clicks setting + isModalBlocking = !isModalBlocking + modalFx.configControl.setBlockOutsideClicks(isModalBlocking) + val message = if (isModalBlocking) { + "模态浮窗现在阻挡外部点击" + } else { + "模态浮窗现在允许外部点击" + } + Toast.makeText(this@BlockOutsideClicksTestActivity, message, Toast.LENGTH_SHORT).show() + } + addItemView("另一个背景可点击按钮") { + Toast.makeText( + this@BlockOutsideClicksTestActivity, + "第二个背景按钮被点击了!", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/petterp/floatingx/app/test/ScopeActivity.kt b/app/src/main/java/com/petterp/floatingx/app/test/ScopeActivity.kt index e18c01cb..d85b906c 100644 --- a/app/src/main/java/com/petterp/floatingx/app/test/ScopeActivity.kt +++ b/app/src/main/java/com/petterp/floatingx/app/test/ScopeActivity.kt @@ -166,6 +166,14 @@ class ScopeActivity : AppCompatActivity() { } } } + addItemView("启用阻挡外部点击") { + scopeFx.configControl.setBlockOutsideClicks(true) + Toast.makeText(this@ScopeActivity, "已启用阻挡外部点击", Toast.LENGTH_SHORT).show() + } + addItemView("禁用阻挡外部点击") { + scopeFx.configControl.setBlockOutsideClicks(false) + Toast.makeText(this@ScopeActivity, "已禁用阻挡外部点击", Toast.LENGTH_SHORT).show() + } } } } diff --git a/build.gradle b/build.gradle index c9f56094..d04e6d63 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { plugins { alias(libs.plugins.vanniketch.maven.publish) apply false alias(libs.plugins.compose.compiler) apply false - alias(libs.plugins.android.lirary) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false } \ No newline at end of file diff --git a/floatingx/src/main/java/com/petterp/floatingx/assist/helper/FxBasisHelper.kt b/floatingx/src/main/java/com/petterp/floatingx/assist/helper/FxBasisHelper.kt index 952396d0..44e428d4 100644 --- a/floatingx/src/main/java/com/petterp/floatingx/assist/helper/FxBasisHelper.kt +++ b/floatingx/src/main/java/com/petterp/floatingx/assist/helper/FxBasisHelper.kt @@ -98,6 +98,9 @@ abstract class FxBasisHelper { @JvmField internal var enableAssistLocation: Boolean = false + @JvmField + internal var enableBlockOutsideClicks: Boolean = false + @JvmField internal var iFxTouchListener: IFxTouchListener? = null @@ -222,6 +225,7 @@ abstract class FxBasisHelper { enableSaveDirection = this@Builder.enableSaveDirection enableClickListener = this@Builder.enableClickListener enableAssistLocation = assistLocation != null + enableBlockOutsideClicks = this@Builder.enableBlockOutsideClicks enableDebugLog = this@Builder.enableDebugLog fxLogTag = this@Builder.fxLogTag @@ -292,6 +296,17 @@ abstract class FxBasisHelper { return this as T } + /** + * 设置是否阻止点击浮窗外部区域,类似Dialog的效果 + * 当启用后,浮窗外部区域将无法响应点击事件,只有浮窗本身可以交互 + * 注意:此功能仅对APP级别的浮窗有效,系统级浮窗不支持此功能 + * @param isEnable 默认false + */ + fun setBlockOutsideClicks(isEnable: Boolean): T { + this.enableBlockOutsideClicks = isEnable + return this as T + } + /** 设置边缘吸附方向,默认 [FxAdsorbDirection.LEFT_OR_RIGHT] */ fun setEdgeAdsorbDirection(direction: FxAdsorbDirection): T { this.edgeAdsorbDirection = direction diff --git a/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasicConfigProvider.kt b/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasicConfigProvider.kt index 8c458a8a..301cdcd4 100644 --- a/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasicConfigProvider.kt +++ b/floatingx/src/main/java/com/petterp/floatingx/imp/FxBasicConfigProvider.kt @@ -55,6 +55,14 @@ open class FxBasicConfigProvider>( internalView?.moveToEdge() } + override fun setBlockOutsideClicks(isEnable: Boolean) { + helper.enableBlockOutsideClicks = isEnable + // Notify the platform provider to update the outside click blocking + if (p is com.petterp.floatingx.imp.app.FxAppPlatformProvider) { + p.updateBlockOutsideClicks() + } + } + override fun setTouchListener(listener: IFxTouchListener) { helper.iFxTouchListener = listener } diff --git a/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt b/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt index 4fe2f57c..810cc25e 100644 --- a/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt +++ b/floatingx/src/main/java/com/petterp/floatingx/imp/app/FxAppPlatformProvider.kt @@ -2,6 +2,7 @@ package com.petterp.floatingx.imp.app import android.app.Activity import android.content.Context +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import androidx.core.view.OnApplyWindowInsetsListener @@ -75,12 +76,84 @@ class FxAppPlatformProvider( } else if (fxView.visibility != View.VISIBLE) { fxView.visibility = View.VISIBLE } + // Update overlay when showing + updateBlockOutsideClicks() } override fun hide() { + // Remove touch interception when hiding + removeTouchInterception() detach() } + /** + * Update the outside click blocking state + */ + fun updateBlockOutsideClicks() { + if (helper.enableBlockOutsideClicks) { + setupTouchInterception() + } else { + removeTouchInterception() + } + } + + private var originalTouchInterceptor: View.OnTouchListener? = null + + private fun setupTouchInterception() { + val containerView = containerGroupView ?: return + + // Save original touch listener if any + if (originalTouchInterceptor == null) { + // Set our touch interceptor + val touchInterceptorTag = "fx_touch_interceptor".hashCode() + originalTouchInterceptor = containerView.getTag(touchInterceptorTag) as? View.OnTouchListener + + val touchInterceptor = View.OnTouchListener { _, event -> + // Check if touch is inside floating window + val fxView = _internalView + if (fxView != null && ViewCompat.isAttachedToWindow(fxView) && fxView.visibility == View.VISIBLE) { + val location = IntArray(2) + fxView.getLocationOnScreen(location) + val fxLeft = location[0] + val fxTop = location[1] + val fxRight = fxLeft + fxView.width + val fxBottom = fxTop + fxView.height + + val x = event.rawX + val y = event.rawY + + // If touch is inside floating window, let it pass through + if (x >= fxLeft && x <= fxRight && y >= fxTop && y <= fxBottom) { + return@OnTouchListener false // Don't consume, let floating window handle + } + + // Touch is outside floating window, consume the event + return@OnTouchListener true + } + + // No floating window visible, don't consume + false + } + + containerView.setTag(touchInterceptorTag, touchInterceptor) + containerView.setOnTouchListener(touchInterceptor) + } + + helper.fxLog.v("Touch interception enabled") + } + + private fun removeTouchInterception() { + val containerView = containerGroupView ?: return + + // Restore original touch listener + val touchInterceptorTag = "fx_touch_interceptor".hashCode() + containerView.setOnTouchListener(originalTouchInterceptor) + containerView.setTag(touchInterceptorTag, null) + originalTouchInterceptor = null + + helper.fxLog.v("Touch interception disabled") + } + private fun checkOrReInitGroupView(): ViewGroup? { val curGroup = containerGroupView if (curGroup == null || curGroup !== topActivity?.decorView) { @@ -127,6 +200,7 @@ class FxAppPlatformProvider( override fun reset() { hide() clearWindowsInsetsListener() + removeTouchInterception() _internalView = null _containerGroup?.clear() _containerGroup = null @@ -137,6 +211,7 @@ class FxAppPlatformProvider( private fun detach() { _internalView?.visibility = View.GONE containerGroupView?.safeRemoveView(_internalView) + removeTouchInterception() _containerGroup?.clear() _containerGroup = null } diff --git a/floatingx/src/main/java/com/petterp/floatingx/listener/control/IFxConfigControl.kt b/floatingx/src/main/java/com/petterp/floatingx/listener/control/IFxConfigControl.kt index b0a23d4c..bf6715a1 100644 --- a/floatingx/src/main/java/com/petterp/floatingx/listener/control/IFxConfigControl.kt +++ b/floatingx/src/main/java/com/petterp/floatingx/listener/control/IFxConfigControl.kt @@ -64,6 +64,14 @@ interface IFxConfigControl { * */ fun setEnableEdgeAdsorption(isEnable: Boolean) + /** + * 设置是否阻止点击浮窗外部区域 + * 当启用后,浮窗外部区域将无法响应点击事件,只有浮窗本身可以交互 + * 注意:此功能仅对APP级别的浮窗有效,系统级浮窗不支持此功能 + * @param isEnable 默认false + */ + fun setBlockOutsideClicks(isEnable: Boolean) + /** 设置滑动监听 */ fun setTouchListener(listener: IFxTouchListener) diff --git a/floatingx/src/test/java/com/petterp/floatingx/test/BlockOutsideClicksTest.kt b/floatingx/src/test/java/com/petterp/floatingx/test/BlockOutsideClicksTest.kt new file mode 100644 index 00000000..a14fba9c --- /dev/null +++ b/floatingx/src/test/java/com/petterp/floatingx/test/BlockOutsideClicksTest.kt @@ -0,0 +1,65 @@ +package com.petterp.floatingx.test + +import org.junit.Test +import org.junit.Assert.* + +/** + * Test for block outside clicks coordinate calculations + * This test validates that the touch coordinate logic is correct + */ +class BlockOutsideClicksTest { + + @Test + fun testTouchInsideFloatingWindow() { + // Simulated floating window bounds + val fxLeft = 100 + val fxTop = 200 + val fxRight = 300 // width = 200 + val fxBottom = 400 // height = 200 + + // Touch coordinates inside the floating window + val touchX = 150f + val touchY = 250f + + // Test the touch detection logic + val isInsideWindow = touchX >= fxLeft && touchX <= fxRight && touchY >= fxTop && touchY <= fxBottom + + assertTrue("Touch should be inside floating window", isInsideWindow) + } + + @Test + fun testTouchOutsideFloatingWindow() { + // Simulated floating window bounds + val fxLeft = 100 + val fxTop = 200 + val fxRight = 300 + val fxBottom = 400 + + // Touch coordinates outside the floating window + val touchX = 50f // Left of window + val touchY = 250f + + // Test the touch detection logic + val isInsideWindow = touchX >= fxLeft && touchX <= fxRight && touchY >= fxTop && touchY <= fxBottom + + assertFalse("Touch should be outside floating window", isInsideWindow) + } + + @Test + fun testTouchOnBoundary() { + // Simulated floating window bounds + val fxLeft = 100 + val fxTop = 200 + val fxRight = 300 + val fxBottom = 400 + + // Touch coordinates on the boundary (should be considered inside) + val touchX = 100f // Exactly on left edge + val touchY = 200f // Exactly on top edge + + // Test the touch detection logic + val isInsideWindow = touchX >= fxLeft && touchX <= fxRight && touchY >= fxTop && touchY <= fxBottom + + assertTrue("Touch on boundary should be considered inside", isInsideWindow) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 419520e4..0f7cef36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,7 +45,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" [plugins] vanniketch-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniketch-maven-publish" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -android-lirary = { id = "com.android.library", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }