Popups & Modals
Coming off Drag & Drop, let's look at another kind of "outside the normal hierarchy" content: things that need to float above everything else.
Many apps need to show content above everything else from time to time — a popup, an overlay, a dropdown. You could do this by hand, adding a top-level View straight to the Display — but you'd quickly run into two problems. That View would be subject to the Display's own Layout, making precise positioning a hassle. And any other top-level View added later could just as easily end up stacked above it.
Doodle solves both problems with PopupManager, which manages a set of Views that sidestep those limitations entirely; and positions them with the same flexible constraints you already know, so they can stay visible or resize based on whatever's around them.
Popups
Any View can become a popup. Call show on the PopupManager, and it lands on the Display above every existing View — other visible popups included.
Include the PopupModule (Web, Desktop) in your application to use these features.
package popups
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.PopupModule
import io.nacular.doodle.application.application
import io.nacular.doodle.controls.PopupManager
import io.nacular.doodle.core.Display
import org.kodein.di.instance
class PopupApp(display: Display, popupManager: PopupManager): Application {
init {
// ..
}
override fun shutdown() {}
}
//sampleStart
fun main() {
application(modules = listOf(PopupModule)) {
PopupApp(display = instance(), popupManager = instance())
}
}
//sampleEndHere's a popup that appears when you click a blue rectangle, and disappears when you click it back:
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.PopupManager
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.center
import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES
import io.nacular.doodle.docs.utils.DEFAULT_FONT_SIZE
import io.nacular.doodle.docs.utils.blueClick
import io.nacular.doodle.docs.utils.clickView
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.event.PointerListener.Companion.clicked
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.utils.Resizer
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class SimplePopup(
display : Display,
popups : PopupManager,
fonts : FontLoader,
images : ImageLoader,
textMetrics : TextMetrics,
uiDispatcher: CoroutineDispatcher,
): Application {
init {
val appScope = CoroutineScope(SupervisorJob() + uiDispatcher)
appScope.launch {
val font = fonts {
families = DEFAULT_FONT_FAMILIES
size = DEFAULT_FONT_SIZE * 2
}!!
val image = images.load("/doodle/images/touch.svg")!!
val popup = clickView(textMetrics).apply {
this.font = font
pointerChanged += clicked { popups.hide(this) }
}
//sampleStart
// Hide popup when it is clicked
popup.pointerChanged += clicked { popups.hide(popup) }
display += blueClick(image).apply {
suggestBounds(Rectangle(display.center - Point(400 / 2, 200 / 2), Size(400, 200)))
// show popup when blue view clicked
pointerChanged += clicked {
popups.show(popup) {
// size / position popup
it.height eq parent.height / 2
it.width eq it.height * 1.5
it.center eq parent.center
}
}
Resizer(this)
}
//sampleEnd
display.fill(White.paint)
}
}
override fun shutdown() {}
}
Unlike Modals, regular popups don't block interaction with the rest of the app.
Relative positioning
Plenty of popups need to stay tethered to another View — dropdown menus, hover callouts, tooltips, that sort of thing. PopupManager makes this just as easy: reach for the variant of show that takes a relativeTo value, and the manager positions the popup relative to that View, according to whatever constraints you give it.
Here's a popup that stays anchored to a blue rectangle — drag the rectangle around while the popup is showing, and watch the popup follow:
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.PopupManager
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.center
import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES
import io.nacular.doodle.docs.utils.DEFAULT_FONT_SIZE
import io.nacular.doodle.docs.utils.blueClick
import io.nacular.doodle.docs.utils.clickView
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.event.PointerListener.Companion.clicked
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.layout.constraints.Strength.Companion.Strong
import io.nacular.doodle.utils.Resizer
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class RelativePopup(
display : Display,
popups : PopupManager,
fonts : FontLoader,
images : ImageLoader,
textMetrics : TextMetrics,
uiDispatcher: CoroutineDispatcher,
): Application {
init {
val appScope = CoroutineScope(SupervisorJob() + uiDispatcher)
appScope.launch {
val font = fonts {
families = DEFAULT_FONT_FAMILIES
size = DEFAULT_FONT_SIZE * 2
}!!
val image = images.load("/doodle/images/touch.svg")!!
val popup = clickView(textMetrics).apply {
this.font = font
pointerChanged += clicked { popups.hide(this) }
}
//sampleStart
// Hide popup when it is clicked
popup.pointerChanged += clicked { popups.hide(popup) }
display += blueClick(image).apply {
suggestBounds(Rectangle(display.center - Point(300 / 2, 200 / 2), Size(300, 200)))
// show popup when blue view clicked
pointerChanged += clicked {
popups.show(popup, relativeTo = this) { popup, blueView ->
// size / position popup
popup.top eq blueView.y strength Strong
popup.left eq blueView.right + 10 strength Strong
popup.bottom lessEq parent.bottom - 5 strength Strong
popup.top greaterEq 5
popup.left greaterEq 5
popup.right lessEq parent.right - 5
popup.height eq parent.height / 2
popup.width eq popup.height * 1.5
}
}
Resizer(this)
}
//sampleEnd
display.fill(White.paint)
}
}
override fun shutdown() {}
}
Modals
A modal is a popup with one extra super-power: it can block further interaction with the rest of your app (aside from any modal that comes after it). That makes modals a natural fit whenever you genuinely need an answer from the user before anything else can proceed. You build one from any View, the same way you'd build a popup, using ModalManager — which, fittingly, uses PopupManager under the hood.
Include the ModalModule (Web, Desktop) in your application to use these features.
package popups
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.ModalModule
import io.nacular.doodle.application.application
import io.nacular.doodle.controls.modal.ModalManager
import io.nacular.doodle.core.Display
import org.kodein.di.instance
class ModalApp(display: Display, modalManager: ModalManager): Application {
init {
// ..
}
override fun shutdown() {}
}
fun example() {
//sampleStart
fun main() {
application(modules = listOf(ModalModule)) {
ModalApp(display = instance(), modalManager = instance())
}
}
//sampleEnd
}
Here's the detail that sets modals apart: they're asynchronous, and they return a value of type <T>. That means you call them from a CoroutineContext, and you simply can't get a result until the modal completes — which turns out to be exactly the shape you want for collecting user input.
Launch one by invoking ModalManager with a lambda returning a Modal<T> or a RelativeModal<T>. Whichever type you return determines the value the modal eventually completes with — and completion itself happens by calling completed from within the creation context:
package popups
import io.nacular.doodle.controls.modal.ModalManager
import io.nacular.doodle.controls.modal.ModalManager.Modal
import io.nacular.doodle.core.view
suspend fun <T> modalCreation(modal: ModalManager, result: T) {
//sampleStart
// launch modal and await result (suspending)
val value: T = modal {
Modal(
// View used as modal
view {
// ...
// call completed with user input when done
completed(result)
}
) {
// optionally provide a layout block
// or the view will default to being
// displayed in the center
}
}
//sampleEnd
}
Two live demos are worth playing with below. The first is a simple modal that drops down from the top and waits for the user to dismiss it, completing with Unit once they click "Ok"
- Demo
- Code
package io.nacular.doodle.docs.apps
import io.nacular.doodle.animation.Animation
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.transition.easeOutBack
import io.nacular.doodle.animation.transition.easeOutBounce
import io.nacular.doodle.animation.transition.linear
import io.nacular.doodle.animation.tweenFloat
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.controls.modal.ModalManager
import io.nacular.doodle.controls.modal.ModalManager.Modal
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.core.center
import io.nacular.doodle.docs.utils.ClickMe
import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES
import io.nacular.doodle.docs.utils.DEFAULT_FONT_SIZE
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.lerp
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.event.PointerListener.Companion.clicked
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.utils.TextAlignment.Start
import io.nacular.doodle.utils.autoCanceling
import io.nacular.doodle.utils.lerp
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Time.Companion.milliseconds
import io.nacular.measured.units.times
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class ModalApp(
display : Display,
modal : ModalManager,
animate : Animator,
themeManager: ThemeManager,
theme : Theme,
fonts : FontLoader,
textMetrics : TextMetrics,
uiDispatcher: CoroutineDispatcher,
): Application {
init {
val appScope = CoroutineScope(SupervisorJob() + uiDispatcher)
appScope.launch {
val font = fonts {
families = DEFAULT_FONT_FAMILIES
size = DEFAULT_FONT_SIZE
}!!
// for basic Button Behavior
themeManager.selected = theme
val clickMe = ClickMe(textMetrics).also { it.font = font }
//sampleStart
clickMe.pointerChanged += clicked {
appScope.launch {
modal {
val popup = createPopup(this::completed).also { it.font = font }
val duration = 250 * milliseconds
var jiggleAnimation: Animation<*>? by autoCanceling()
// Shake the popup when the pointer clicked outside of it
pointerOutsideModalChanged += clicked {
jiggleAnimation = animate(1f to 0f, tweenFloat(easeOutBounce, duration)) {
popup.transform = Identity.rotate(around = popup.center, by = 2 * degrees * it)
}
}
var layoutProgress = 0f
animate {
// Animate background
0f to 1f using (tweenFloat(linear, duration)) {
background = lerp(Lightgray opacity 0f, Lightgray opacity 0.5f, it).paint
}
// Animate layout
0f to 1f using (tweenFloat(easeOutBack, duration)) {
layoutProgress = it // modify values used in layout
reLayout() // ask modal to update its layout
}
}
// Show modal with popup using the given layout constraints
Modal(popup) {
it.size eq it.idealSize
it.centerX eq parent.centerX
it.centerY eq lerp(-it.height.value / 2, parent.centerY.value, layoutProgress)
}
}
}
}
//sampleEnd
display += clickMe
display.fill(White.paint)
display.layout = constrain(display.first(), fill)
}
}
private fun createPopup(completed: (Unit) -> Unit) = object: View() {
init {
clipCanvasToBounds = false
children += Label("Thanks for clicking. Now please press Ok to acknowledge.").apply {
wrapsWords = true
textAlignment = Start
}
children += PushButton("Ok").apply { fired += { completed(Unit) } }
layout = constrain(children[0], children[1]) { label, button ->
label.top eq 20
label.left eq 20
label.right eq parent.right - 20
button.top eq readonly(label.bottom) + 20
button.width eq parent.width / 4
button.height eq 30
button.centerX eq parent.centerX
parent.width eq 300
parent.height eq button.bottom + 20
}
}
override fun render(canvas: Canvas) {
canvas.outerShadow(blurRadius = 10.0, color = Black opacity 0.05f) {
rect(bounds.atOrigin, radius = 10.0, fill = White.paint)
}
}
}
override fun shutdown() {}
}The second is asks the user to choose between two Color`s, with its own background treatment and dismiss animation.
- Demo
- Code
package io.nacular.doodle.docs.apps
import io.nacular.doodle.animation.Animation
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.transition.easeOutBounce
import io.nacular.doodle.animation.transition.linear
import io.nacular.doodle.animation.tweenFloat
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.controls.modal.ModalManager
import io.nacular.doodle.controls.modal.ModalManager.Modal
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.core.center
import io.nacular.doodle.docs.utils.ClickMe
import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES
import io.nacular.doodle.docs.utils.DEFAULT_FONT_SIZE
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Blue
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Color.Companion.Pink
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.lerp
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.stripedPaint
import io.nacular.doodle.event.PointerListener.Companion.clicked
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.utils.TextAlignment
import io.nacular.doodle.utils.autoCanceling
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Time.Companion.milliseconds
import io.nacular.measured.units.times
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class RedOrBlueApp(
display : Display,
modal : ModalManager,
animate : Animator,
themeManager: ThemeManager,
theme : Theme,
fonts : FontLoader,
textMetrics : TextMetrics,
uiDispatcher: CoroutineDispatcher,
): Application {
init {
val appScope = CoroutineScope(SupervisorJob() + uiDispatcher)
appScope.launch {
val font = fonts {
families = DEFAULT_FONT_FAMILIES
size = DEFAULT_FONT_SIZE
}!!
// for basic Button Behavior
themeManager.selected = theme
val clickMe = ClickMe(textMetrics).also { it.font = font }
//sampleStart
clickMe.pointerChanged += clicked {
appScope.launch {
val color = modal {
val popup = createPopup(this::completed).also { it.font = font; it.opacity = 0f }
val duration = 250 * milliseconds
var bounceAnimation: Animation<*>? by autoCanceling()
// Bounce the popup when the pointer clicked outside of it
pointerOutsideModalChanged += clicked {
bounceAnimation = animate(1f to 0f, tweenFloat(easeOutBounce, duration)) {
popup.transform = Identity.scale(around = popup.center, 1.0 - (.05 * it), 1.0 - (.05 * it))
}
}
animate {
// Animate background
0f to 1f using (tweenFloat(linear, duration)) {
popup.opacity = it
background = stripedPaint(
stripeWidth = 50.0,
evenRowColor = lerp(Lightgray opacity 0f, Pink opacity 0.25f, it),
transform = Identity.rotate(around = display.center, by = 45 * degrees * it)
)
}
}
// Show modal with popup (defaults to center layout)
Modal(popup) {
it.size eq it.idealSize
it.center eq parent.center
}
}
clickMe.bangColor = color
}
}
//sampleEnd
display += clickMe
display.fill(White.paint)
display.layout = constrain(display.first(), fill)
}
}
private fun createPopup(completed: (Color) -> Unit) = object: View() {
init {
clipCanvasToBounds = false
children += Label("Which pill?").apply {
wrapsWords = true
TextAlignment.Center.also { textAlignment = it }
}
children += PushButton("Red" ).apply { fired += { completed(Red ) } }
children += PushButton("Blue").apply { fired += { completed(Blue) } }
layout = constrain(children[0], children[1], children[2]) { label, red, blue ->
label.top eq 20
label.left eq 20
label.right eq parent.right - 20
red.top eq readonly(label.bottom) + 20
red.width eq parent.width / 4
red.right eq parent.centerX - 5
red.height eq 30
blue.top eq red.top
blue.left eq red.right + 10
blue.width eq red.width
blue.height eq red.height
parent.width eq 300
parent.height eq blue.bottom + 20
}
}
override fun render(canvas: Canvas) {
canvas.outerShadow(blurRadius = 10.0, color = Black opacity 0.05f) {
rect(bounds.atOrigin, radius = 10.0, fill = White.paint)
}
}
}
override fun shutdown() {}
}Relative positioning for modals
Modals can anchor to another View, exactly the way popups do — just return a RelativeModal instead when you create one
package popups
import io.nacular.doodle.controls.modal.ModalManager
import io.nacular.doodle.controls.modal.ModalManager.RelativeModal
import io.nacular.doodle.core.view
suspend fun <T> relativeModal(modal: ModalManager, result: T) {
//sampleStart
val someView = view {}
// launch modal and await result (suspending)
val value: T = modal {
RelativeModal(
// View used as modal
view {
// ...
// call completed when the modal is done
completed(result)
},
relativeTo = someView
) { modal, someViewBounds ->
// position relative to parent and someView
}
}
//sampleEnd
}
Recap
PopupManager> floats anyViewabove the rest of your app, sidestepping theDisplay's ownLayout> and stacking-order limitations.- Popups don't block interaction; modals do — and modals are
suspend, returning a value once the user responds. - Position either one relative to another
Viewwith therelativeTovariant ofshow, or by returning aRelativeModalinstead of aModal.
Next: on to Accessibility, the last stop in Advanced Topics.