kotlinandroid-jetpack-composeandroid-widgetandroid-viewnested-scroll

Compose AndroidView nested scrolling with app widgets not working


In my custom Android launcher I have a compose AndroidView containing an AppWidgetHostView inside of a vertically scrollable compose Column.

This is my code, the WidgetComposable is contained in a Column with the verticalScroll() modifier:

@Composable
fun WidgetComposable(
    widget: Widget,
    widgetsViewModel: WidgetsViewModel
) {
    val context = LocalContext.current

    BoxWithConstraints(
        modifier = Modifier
            .fillMaxWidth()
            .height(250.dp)
                .scrollable(
                    state = rememberScrollableState { it },
                    orientation = Vertical
                )
    ) {
        AndroidView(
            modifier = Modifier.fillMaxSize(),
            factory = {
                widgetsViewModel.getViewForWidget(widget, context)
                    .apply {
                        ViewCompat.setNestedScrollingEnabled(this, true)
                    }
            },
            update = { widgetView ->
                widgetView.updateAppWidgetOptions(Bundle().apply {
                    putInt(OPTION_APPWIDGET_MIN_WIDTH, maxWidth.int)
                    putInt(OPTION_APPWIDGET_MIN_HEIGHT, maxHeight.int)
                    putInt(OPTION_APPWIDGET_MAX_WIDTH, maxWidth.int)
                    putInt(OPTION_APPWIDGET_MAX_HEIGHT, maxHeight.int)
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                        putParcelableArrayList(
                            OPTION_APPWIDGET_SIZES,
                            arrayListOf(SizeF(maxWidth.value, maxHeight.value))
                        )
                    }
                })
                Log.i(TAG, "Updated widget size")
            }
        )
    }
}

private val Dp.int get() = value.toInt()

I followed the second example on how to set up nested scrolling given here: https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view. However, it doesn't work for some reason, scrolling on the widgets scrolls neither the widget content nor the column. I have uploaded a minimal example of the issue on GitHub: https://github.com/UOBW/WidgetTest.

I've tried many things to get this to work, the best solution I've found is to search through the AppWidgetHostView view hierarchy for any scrollable layouts and then, if that's the case, use a custom subclass of AppWidgetHostView that calls requestDisallowInterceptTouchEvent() inside onInterceptTouchEvent(). This prevents the Column from intercepting any touch events fired on the widget. However, there are several disadvantages:


Solution

  • Here's a solution that shows how to handle long presses and also provides a callback for when the user touches the widget. Based on the "is touched" information you can disable / enable the scrolling inside a compose container.

    Code

    // -----------------
    // Usage
    // -----------------
    
    val info : AppWidgetProviderInfo = ...
    val widgetView : LauncherAppWidgetHostView = ...
    val widgetRows: Int = 
    
    AndroidView(
        modifier = Modifier
            .fillMaxWidth()
            .sizeIn(
                minHeight = info.minHeight.pxToDp.dp,
                minWidth = info.minWidth.pxToDp.dp
            )
            .height(heightInDp?.let { with(LocalDensity.current) { it.toDp() } } ?: (102.dp * widgetRows))
            //.sizeIn(
            //    maxHeight = widget.appWidgetInfo.maxResizeHeight.pxToDp.dp,
            //    maxWidth = widget.appWidgetInfo.maxResizeWidth.pxToDp.dp
            //)
            .onSizeChanged {
                widgetView.updateSize(it.width.pxToDp, it.height.pxToDp)
            },
        factory = { context -> widgetView },
        update = { view ->
            view.onLongPress = onLongPress
            view.onIsTouched = { touched -> 
                // TODO: enable/disable scrolling in your parent container depending on this state
            }
        },
        //onReset = { view ->
        //    view.onLongPress = null
        //    view.onIsTouched = null
        //},
        //onRelease = { view ->
        //    view.onLongPress = null
        //    view.onIsTouched = null
        //}
    )
    

    AppWidgetProvider

    // -----------------
    // AppWidgetHostView
    // -----------------
    
    class LauncherAppWidgetHostView(context: Context) : AppWidgetHostView(context) {
    
        private var hasPerformedLongPress: Boolean = false
        private var pointDown = PointF()
        private val pendingCheckForLongPress = CheckForLongPress()
        private val pendingCheckTouched = CheckTouched()
        private val threshold: Int = 5.dpToPx
    
        // callbacks
        var onLongPress: (() -> Unit)? = null
        var onIsTouched: ((touched: Boolean) -> Unit)? = null
    
        init {
    
            // Hardware Acceleration - deactivate on android O or higher
            //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            //    setLayerType(View.LAYER_TYPE_SOFTWARE, null)
            //}
    
        }
    
        override fun updateAppWidget(remoteViews: RemoteViews?) {
            // can happen, no idea why (maybe if the widget itself has a bug?)... we better catch it to avoid that a widget can crash the whole app
            try {
                println("widget - updateAppWidget: $remoteViews | ${remoteViews?.`package`}")
                super.updateAppWidget(remoteViews)
            } catch (e: Exception) {
                L.e(e)
            }
    
        }
    
        override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    
            // Consume any touch events for ourselves after longpress is triggered
            if (hasPerformedLongPress) {
                onIsTouched?.invoke(false)
                hasPerformedLongPress = false
                println("widget - hasPerformedLongPress was true!")
                return true
            }
    
            //L.d { "onInterceptTouchEvent: ev = ${ev.action} | x = ${ev.x}  | y = ${ev.y}" }
    
            // Watch for longpress events at this level to make sure
            // users can always pick up this widget
            when (ev.action) {
                MotionEvent.ACTION_DOWN -> {
                    pointDown.set(ev.x, ev.y)
                    onIsTouched?.invoke(true)
                    postCheckForLongClick()
                    println("widget - action down")
                }
    
                MotionEvent.ACTION_UP,
                MotionEvent.ACTION_CANCEL -> {
                    hasPerformedLongPress = false
                    removeCallbacks(pendingCheckForLongPress)
                    onIsTouched?.invoke(false)
                    println("widget - action up/cancel")
                }
    
                MotionEvent.ACTION_MOVE -> {
                    val diffX = Math.abs(pointDown.x - ev.x)
                    val diffY = Math.abs(pointDown.y - ev.y)
                    //L.d { "onInterceptTouchEvent: diffX = $diffX | diffY = $diffY | mThreshold = $mThreshold" }
                    if (diffX >= threshold || diffY >= threshold) {
                        hasPerformedLongPress = false
                        removeCallbacks(pendingCheckForLongPress)
                    }
                    onIsTouched?.invoke(true)
                    postCheckTouched()
                    println("widget - action move | $ev")
                }
    
                else -> {
                    println("widget - action else")
                }
            }
    
            // Otherwise continue letting touch events fall through to children
            return false
        }
    
        internal inner class CheckForLongPress : Runnable {
            private var originalWindowAttachCount: Int = 0
    
            override fun run() {
    
                println("widget - CheckForLongPress-run: $parent | $windowAttachCount | $originalWindowAttachCount | $hasPerformedLongPress")
    
                if (parent != null
                    //    hasWindowFocus()
                    && originalWindowAttachCount == windowAttachCount
                    && !hasPerformedLongPress
                ) {
                    println("widget - before performLongClick...")
                    //if (performLongClick()) {
                    onLongPress?.invoke()
                    println("widget - onLongPress")
                    hasPerformedLongPress = true
                    //}
                }
            }
    
            fun rememberWindowAttachCount() {
                originalWindowAttachCount = windowAttachCount
            }
        }
    
        internal inner class CheckTouched : Runnable {
            private var originalWindowAttachCount: Int = 0
            override fun run() {
    
                if (parent != null
                    //    hasWindowFocus()
                    && originalWindowAttachCount == windowAttachCount
                ) {
                    onIsTouched?.invoke(false)
                }
            }
    
            fun rememberWindowAttachCount() {
                originalWindowAttachCount = windowAttachCount
            }
        }
    
        private fun postCheckTouched() {
            removeCallbacks(pendingCheckTouched)
            pendingCheckTouched.rememberWindowAttachCount()
            postDelayed(pendingCheckTouched, 500)
        }
    
        private fun postCheckForLongClick() {
            removeCallbacks(pendingCheckForLongPress)
            hasPerformedLongPress = false
            if (onLongPress == null) {
                return
            }
            pendingCheckForLongPress.rememberWindowAttachCount()
            postDelayed(pendingCheckForLongPress, ViewConfiguration.getLongPressTimeout().toLong())
        }
    
        override fun cancelLongPress() {
            super.cancelLongPress()
            hasPerformedLongPress = false
            removeCallbacks(pendingCheckForLongPress)
        }
    
        //override fun getDescendantFocusability() = ViewGroup.FOCUS_BLOCK_DESCENDANTS
    }