Skip to content
jonikay edited this page Jul 5, 2026 · 3 revisions

BottomShelfer — Code Recipes

Copy each example into your project and adapt as needed.


Basic presentation

import com.bottomshelfer.BottomShelferDetent
import com.bottomshelfer.BottomShelferDialog
import com.bottomshelfer.BottomShelferSheet

val sheet = BottomShelferSheet(context)
sheet.setDetents(listOf(BottomShelferDetent.medium(context)))

val content = TextView(context).apply {
    text = "Hello, BottomShelfer!"
    setPadding(24, 60, 24, 24)
    setTextColor(Color.BLACK)
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.dismissOnHide = true
dialog.show()

Multi-detent with snap buttons

val sheet = BottomShelferSheet(context)
sheet.setDetents(listOf(
    BottomShelferDetent.small(context),
    BottomShelferDetent.medium(context),
    BottomShelferDetent.large(context)
))
sheet.setSelectedDetentIndex(1)

val content = LinearLayout(context).apply {
    orientation = LinearLayout.VERTICAL
    setPadding(24, 60, 24, 24)

    addView(Button(context).apply {
        text = "Snap to small (25%)"
        setOnClickListener {
            sheet.snapToHeight(BottomShelferDetent.small(context).height)
        }
    })

    addView(Button(context).apply {
        text = "Snap to large (90%)"
        setOnClickListener {
            sheet.snapToHeight(BottomShelferDetent.large(context).height)
        }
    })

    addView(Button(context).apply {
        text = "Dismiss"
        setOnClickListener { sheet.parentDialog?.dismiss() }
    })
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.show()

Custom layout

val sheet = BottomShelferSheet(context)
sheet.config = BottomShelferLayoutConfig(
    maxSheetWidthDp = 500,
    grabberPillWidthDp = 60,
    grabberPillHeightDp = 8,
    grabberPillBottomOffsetDp = 14,
    maxHeightFraction = 0.6f,
    cornerRadiusDp = 28f,
    dimmingColor = 0x66000000.toInt(),
)
sheet.setDetents(BottomShelferDetent.detentsForContentHeight(1000, context, 0.6f))
sheet.setSelectedDetentIndex(2)

val content = TextView(context).apply {
    text = "Custom layout: wider, bigger grabber.\n60% max height, 28pt corners."
    setPadding(24, 60, 24, 24)
    setTextColor(Color.BLACK)
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.dismissOnHide = true
dialog.show()

No dimming scrim

val sheet = BottomShelferSheet(context)
sheet.config = sheet.config.copy(isDimmingEnabled = false)
sheet.setDetents(listOf(BottomShelferDetent.medium(context)))

val content = TextView(context).apply {
    text = "No dimming scrim behind this sheet."
    setPadding(24, 60, 24, 24)
    setTextColor(Color.BLACK)
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.dismissOnHide = true
dialog.show()

Non-draggable (button dismiss only)

val sheet = BottomShelferSheet(context)
sheet.config = sheet.config.copy(isDraggingEnabled = false)
sheet.setDetents(listOf(BottomShelferDetent.small(context)))
sheet.setSelectedDetentIndex(0)

val content = LinearLayout(context).apply {
    orientation = LinearLayout.VERTICAL
    setPadding(24, 60, 24, 24)

    addView(TextView(context).apply {
        text = "This sheet cannot be dragged."
        setTextColor(Color.BLACK)
    })

    addView(Button(context).apply {
        text = "Dismiss"
        setOnClickListener { sheet.parentDialog?.dismiss() }
    })
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.show()

Hidden grabber pill (drag still works)

val sheet = BottomShelferSheet(context)
sheet.config = BottomShelferLayoutConfig(
    grabberPillWidthDp = 0,
    grabberPillHeightDp = 0,
    grabberPillBottomOffsetDp = 0,
)
sheet.setDetents(listOf(
    BottomShelferDetent.small(context),
    BottomShelferDetent.medium(context)
))
sheet.setSelectedDetentIndex(1)

val content = TextView(context).apply {
    text = "Hidden grabber pill.\nDrag still works via the top area!"
    setPadding(24, 60, 24, 24)
    setTextColor(Color.BLACK)
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.dismissOnHide = true
dialog.show()

Keyboard avoidance

val sheet = BottomShelferSheet(context)
sheet.autoFocus = true
sheet.setDetents(listOf(BottomShelferDetent.custom(500)))
sheet.setSelectedDetentIndex(0)

val content = LinearLayout(context).apply {
    orientation = LinearLayout.VERTICAL
    setPadding(24, 60, 24, 24)

    addView(TextView(context).apply {
        text = "Tap the field — the sheet lifts for the keyboard."
        setTextColor(Color.BLACK)
    })

    addView(EditText(context).apply {
        hint = "Type something..."
        inputType = InputType.TYPE_CLASS_TEXT
    })

    addView(Button(context).apply {
        text = "Dismiss"
        setOnClickListener { sheet.parentDialog?.dismiss() }
    })
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.dismissOnHide = true
dialog.show()

Set sheet.autoFocus = true to automatically focus the first EditText and show the keyboard when the sheet appears.


Callbacks — drag, dismiss, detent change

sheet.callback = object : BottomShelferCallback {
    override fun onDismiss() {
        Log.d("BottomShelfer", "Sheet dismissed")
    }

    override fun onGrabberDragBegan() {
        Log.d("BottomShelfer", "Grabber drag started")
    }

    override fun onGrabberDragEnded() {
        Log.d("BottomShelfer", "Grabber drag ended")
    }

    override fun onContentDragBegan() {
        Log.d("BottomShelfer", "Content drag started")
    }

    override fun onContentDragEnded() {
        Log.d("BottomShelfer", "Content drag ended")
    }

    override fun onDetentChanged(index: Int, height: Int) {
        Log.d("BottomShelfer", "Snapped to detent $index at ${height}pt")
    }
}

Scrollable content

val sheet = BottomShelferSheet(context)
sheet.setDetents(listOf(
    BottomShelferDetent.small(context),
    BottomShelferDetent.medium(context),
    BottomShelferDetent.large(context)
))
sheet.setSelectedDetentIndex(1)

val recyclerView = RecyclerView(context).apply {
    layoutManager = LinearLayoutManager(context)
    adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
            object : RecyclerView.ViewHolder(
                TextView(parent.context).apply {
                    layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
                    setPadding(48, 24, 48, 24)
                    setTextColor(Color.BLACK)
                }
            ) {}

        override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
            (holder.itemView as TextView).text = "Row ${position + 1}"
        }

        override fun getItemCount() = 50
    }
}
sheet.addContentView(recyclerView,
    ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))

val dialog = BottomShelferDialog(context, sheet)
dialog.show()

Nested scrolling with RecyclerView, ScrollView, and NestedScrollView is handled automatically.


Jetpack Compose inside a bottom sheet

val sheet = BottomShelferSheet(context)
sheet.setDetents(listOf(
    BottomShelferDetent.small(context),
    BottomShelferDetent.medium(context),
    BottomShelferDetent.large(context)
))
sheet.setSelectedDetentIndex(1)

val content = LinearLayout(context).apply {
    orientation = LinearLayout.VERTICAL
    setPadding(24, 60, 24, 24)

    val composeView = ComposeView(context).apply {
        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        setViewTreeLifecycleOwner(activity)
        setViewTreeViewModelStoreOwner(activity)
        setViewTreeSavedStateRegistryOwner(activity)
        setContent {
            Column(
                modifier = Modifier.fillMaxWidth(),
                verticalArrangement = Arrangement.spacedBy(16.dp)
            ) {
                var name by remember { mutableStateOf("") }
                TextField(
                    value = name,
                    onValueChange = { name = it },
                    label = { Text("Name") },
                    modifier = Modifier.fillMaxWidth()
                )
                var enabled by remember { mutableStateOf(true) }
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text("Enabled")
                    Spacer(Modifier.weight(1f))
                    Switch(checked = enabled, onCheckedChange = { enabled = it })
                }
            }
        }
    }
    addView(composeView, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
}
sheet.addContentView(content)

val dialog = BottomShelferDialog(context, sheet)
dialog.show()

Custom grabber (replace default pill)

Hide the built-in pill and add your own view at the top of the sheet, using callbacks to animate it during drag.

val sheet = BottomShelferSheet(context)
sheet.config = sheet.config.copy(
    grabberPillWidthDp = 0,
    grabberPillHeightDp = 0,
    grabberPillBottomOffsetDp = 0,
)
sheet.setDetents(listOf(
    BottomShelferDetent.small(context),
    BottomShelferDetent.medium(context)
))
sheet.setSelectedDetentIndex(1)

class RainbowGrabberView(context: Context) : View(context) {
    private val colors = intArrayOf(
        Color.RED, Color.rgb(255, 165, 0), Color.YELLOW,
        Color.GREEN, Color.BLUE, Color.rgb(128, 0, 128)
    )
    private var gradient: LinearGradient? = null

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        gradient = LinearGradient(0f, 0f, w.toFloat(), 0f, colors, null, Shader.TileMode.CLAMP)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        paint.shader = gradient
        canvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 6f, 6f, paint)
    }
}

val rainbowGrabber = RainbowGrabberView(context)
sheet.callback = object : BottomShelferCallback {
    override fun onGrabberDragBegan() {
        rainbowGrabber.animate()
            .scaleX(1.4f).scaleY(1.8f)
            .alpha(0.5f)
            .setDuration(200)
            .start()
    }
    override fun onGrabberDragEnded() {
        rainbowGrabber.animate()
            .scaleX(1.0f).scaleY(1.0f)
            .alpha(1.0f)
            .setDuration(200)
            .start()
    }
}

val container = FrameLayout(context)
container.addView(rainbowGrabber, FrameLayout.LayoutParams(96, 12).apply {
    gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
    topMargin = 8
})
sheet.addContentView(container)

val dialog = BottomShelferDialog(context, sheet)
dialog.dismissOnHide = true
dialog.show()

All layout configuration options

val sheet = BottomShelferSheet(context)
sheet.config = BottomShelferLayoutConfig(
    maxSheetWidthDp = 430,              // clamp width on tablets
    maxHeightFraction = 0.9f,           // % of screen height
    grabberHitAreaHeightDp = 44,        // draggable band height
    grabberPillWidthDp = 60,            // pill width
    grabberPillHeightDp = 8,            // pill height
    grabberPillColor = 0x99000000.toInt(), // pill color (ARGB)
    grabberPillBottomOffsetDp = 12,     // distance from sheet top edge
    grabberPillCornerRadiusDp = 2.5f,   // pill corner rounding
    cornerRadiusDp = 28f,               // sheet corner rounding
    isDimmingEnabled = true,            // show dimming scrim behind sheet
    isDraggingEnabled = true,           // allow drag gestures
    dimmingColor = 0x4D000000.toInt(),  // scrim color (ARGB)
)

Dismiss behavior control

Set dismissOnHide on the dialog:

  • true — tapping the dimming scrim immediately dismisses the sheet.
  • false (default) — first tap on the scrim dismisses the keyboard (if visible); second tap dismisses the sheet.
val dialog = BottomShelferDialog(context, sheet)
dialog.dismissOnHide = true   // immediate dismiss on scrim tap
dialog.show()