Search code examples
kotlincompose-desktopcompose-multiplatform

Compose Desktop DropdownMenu with vertical scroll bar


I'm taking a DropdownMenu from Kotlin Compose for Desktop and I want to include a vertical scroll bar. The source code for the DropdownMenu is here. They have a sample which works fine but I can't get it to display the vertical scroll bar. It doesn't display by default.

There is a VerticalScrollbar example also that I have attempted but I haven't gotten it to work with the DropdownMenu.

Putting a verticalScroll() within the DropdownMenu(Modifier) results in an error Vertically scrolled component was measured with an infinity maximum height constraints, which is disallowed....

Adding a VerticalScrollbar() beneath the DropdownMenu results in an error Can't represent a size of 1073741824 in Constraints.

So as far as I can tell there is something in the DropdownMenu's Popup or something like that which is making this difficult for me.

Is there a way I can implement the visible scroll bar? My layout is like this so far. (you can scroll down to the Dropdown menu below...)

data class Lookup(val id: String, val name: String)

fun main() = application {
   Window(
      onCloseRequest = ::exitApplication,
      state = rememberWindowState(width = 1280.dp, height = 800.dp)
   ) {
      MaterialTheme {
         Scaffold {
            Column(
               modifier = Modifier
                  .fillMaxSize()
                  .verticalScroll(rememberScrollState()),
               horizontalAlignment = Alignment.CenterHorizontally
            ) {
               val lookups = LookupService.getLookups() // about 75 items
               val (expanded, setExpanded) = remember { mutableStateOf(false) }
               val (selected, setSelected) = remember { mutableStateOf<Lookup?>(null) }

               Spacer(Modifier.height(20.dp))
               Box(Modifier.wrapContentSize(Alignment.TopStart)) {
                  val icon = if (expanded) {
                     Icons.Filled.KeyboardArrowUp
                  } else {
                     Icons.Filled.KeyboardArrowDown
                  }
                  OutlinedTextField(
                     value = selected?.name ?: "",
                     onValueChange = { },
                     modifier = Modifier
                        .width(360.dp)
                        .onKeyEvent {
                           if (it.key == Key.DirectionDown && !expanded) {
                              setExpanded(true)
                              return@onKeyEvent true
                           }
                           return false
                        }
                        .clickable { setExpanded(true) },
                     singleLine = true,
                     label = { Text("Select an item") },
                     trailingIcon = {
                        Icon(icon, "Select an item", Modifier.clickable {
                           setExpanded(!expanded)
                        })
                     },
                     enabled = expanded,
                     colors = TextFieldDefaults.textFieldColors(
                        disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current),
                        backgroundColor = Color.Transparent
                     )
                  )

                  DropdownMenu( // Desktop version, so it creates a "Popup" per the source code
                     expanded = expanded,
                     onDismissRequest = {
                        runBlocking { // to handle a glitch where the dropdown may "unexpand & expand" again on clicking
                           delay(200)
                           setExpanded(false)
                        }
                     },
                     modifier = Modifier
                        .width(360.dp)
                        .background(Color.White)
                        .clip(RoundedCornerShape(5.dp))
                     // SHOULD HAVE VERTICAL SCROLLBAR SHOW UP AS PART OF THIS DROPDOWNMENU COLUMN
                  ) {
                     lookups.forEach { lookup -> 
                        DropdownMenuItem(
                           onClick = {
                              setExpanded(false)
                              setSelected(lookup)
                           }
                        ) {
                           Text(lookup.name)
                        }
                     }
                  }
               }
            }
         }
      }
   }
}

Solution

  • I found the answer in a currently open issue Scrollbar doesn't work for DropdownMenu #587.

    This is how I just got my scrollbar working within the Desktop version of DropdownMenu.

    1. Copy this version of DesktopMenu.desktop.kt into your project.
    2. Copy this version of Menu.kt into your project.
    3. Add this Card into your Menu.kt, wrapping around the Column which displays the content.
    4. As per this comment in the issue, add this into the Card from point 3.
    Box(
        modifier = modifier
            .width(IntrinsicSize.Max)
    ) {
        val scrollState = rememberScrollState()
        var columnSize by remember { mutableStateOf<IntSize?>(null) }
        Column(
            modifier = Modifier
                .padding(vertical = DropdownMenuVerticalPadding)
                .verticalScroll(scrollState)
                .onSizeChanged { size ->
                    columnSize = size
                },
            content = content
        )
        columnSize?.let { size ->
            VerticalScrollbar(
                modifier = Modifier
                    .align(Alignment.CenterEnd)
                    .height(with(LocalDensity.current) { size.height.toDp() }),
                scrollState = scrollState
            )
        }
    }
    
    1. Last point is that this then would overflow above my screen, so I went back to the current version of DesktopMenu.desktop.kt to put back in this version of DesktopDropdownMenuPositionProvider into my DesktopMenu.desktop.kt.
    /**
     * Positions a dropdown relative to another widget (its anchor).
     */
    @Immutable
    internal data class DesktopDropdownMenuPositionProvider(
        val contentOffset: DpOffset,
        val density: Density,
        val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> }
    ) : PopupPositionProvider {
        override fun calculatePosition(
            anchorBounds: IntRect,
            windowSize: IntSize,
            layoutDirection: LayoutDirection,
            popupContentSize: IntSize
        ): IntOffset {
    
            val isLtr = layoutDirection == LayoutDirection.Ltr
    
            // Coerce such that this..this+size fits into min..max; if impossible, align with min
            fun Int.coerceWithSizeIntoRangePreferMin(size: Int, min: Int, max: Int) = when {
                this < min -> min
                this + size > max -> max - size
                else -> this
            }
    
            // Coerce such that this..this+size fits into min..max; if impossible, align with max
            fun Int.coerceWithSizeIntoRangePreferMax(size: Int, min: Int, max: Int) = when {
                this + size > max -> max - size
                this < min -> min
                else -> this
            }
    
            fun Int.coerceWithSizeIntoRange(size: Int, min: Int, max: Int) = when {
                isLtr -> coerceWithSizeIntoRangePreferMin(size, min, max)
                else -> coerceWithSizeIntoRangePreferMax(size, min, max)
            }
    
            // The min margin above and below the menu, relative to the screen.
            val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() }
            // The content offset specified using the dropdown offset parameter.
            val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
            val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
    
            // Compute horizontal position.
            val preferredX = if (isLtr) {
                anchorBounds.left + contentOffsetX
            }
            else {
                anchorBounds.right - contentOffsetX - popupContentSize.width
            }
            val x = preferredX.coerceWithSizeIntoRange(
                size = popupContentSize.width,
                min = 0,
                max = windowSize.width
            )
    
            // Compute vertical position.
            val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
            val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
            val toCenter = anchorBounds.top - popupContentSize.height / 2
            val toWindowBottom = windowSize.height - popupContentSize.height - verticalMargin
            var y = sequenceOf(toBottom, toTop, toCenter, toWindowBottom).firstOrNull {
                it >= verticalMargin &&
                    it + popupContentSize.height <= windowSize.height - verticalMargin
            } ?: toTop
    
            // Desktop specific vertical position checking
            val aboveAnchor = anchorBounds.top + contentOffsetY
            val belowAnchor = windowSize.height - anchorBounds.bottom - contentOffsetY
    
            if (belowAnchor >= aboveAnchor) {
                y = anchorBounds.bottom + contentOffsetY
            }
    
            if (y + popupContentSize.height > windowSize.height) {
                y = windowSize.height - popupContentSize.height
            }
    
            y = y.coerceAtLeast(0)
    
            onPositionCalculated(
                anchorBounds,
                IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height)
            )
            return IntOffset(x, y)
        }
    }