2

EDIT: This bug was fixed in Compose 2025.08, nested scrolling inside AndroidViews now works out of the box, see https://android-developers.googleblog.com/2025/08/whats-new-in-jetpack-compose-august-25-release.html.

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:

  • It also intercepts horizontal scroll events
  • It probably doesn't work with Jetpack Glance
  • It's going to break if Google at some point decides to allow more scrollable views in widgets than those listed here
9
  • Maybe you have the same problem as I had: I had a scrollable container and placed my widget in it. In this case the scroll container (LazyColumn e.g.) does not support scrollable widgets inside it by default... Now I can get it working with a few smaller problems though. Could you solve the issue yourself already? My solution works but has small drawbacks though Commented Jan 28 at 13:30
  • @prom85 My current solution is to always scroll the container if you scroll with one finger, and to scroll the widget if you scroll with two fingers. How did you solve the problem? Commented Feb 7 at 0:17
  • 1
    the best solution I could find was disabling scroll in the container if the touch down is above a widget and enable it otherwise. The drawback is that non scrollable widgets also disable the container scroll because it's hard to detect if a widget does contain any scrollable content or not... Commented Feb 7 at 9:13
  • 1
    I added a long press listener to my widget so I do some stuff in the view system already. In my AppWidgetHostView I use the onInterceptTouchEvent to detect touch down and up/cancel and use this to enable/disable the scrolling feature of my LazyColumn. Commented Feb 10 at 7:10
  • 1
    I made a gist here: gist.github.com/MFlisar/bb886f0e4a3a2f0e55dc1423495eade3 - be aware its just a starting point, it seems to work mostly but surely needs some rework, which I have not done yet (I only use it personally yet in my own launcher app) Commented Feb 18 at 10:04

1 Answer 1

1

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
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.