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:
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
}