Popups and modals
May apps need to show content above all other content periodically in the form of a popup, or overlay. You can do this manually by adding a top-level View
to the Display
, but this has limitations. This approach would subject the View to the Display
's Layout
, so controlling its position would be difficult. Moreover, new View
s added to the Display
could easily be placed above that View
.
Doodle provides a solution to address this use case via the PopupManager
. You can use this component to manage a set of Views
that overcome the limitations mentioned above. Moreover, popups shown using the manager can be positioned with flexible and powerful constraints to ensure they remain visible, or change size based on the items around them.
Popups
Any View
can be shown as a popup. This will add the it to the Display
above all existing View
s, including other popups that are visible. This is done by invoking show
on the PopupManager
.
You must include the PopupModule
(Web, Desktop) in your application in order 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())
}
}
//sampleEnd
Doodle uses opt-in modules like this to improve bundle size.
The following shows a simple popup that is shown whenever you click on the blue rectangle. Clicking the popup hides it again.
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.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 {
size = Size(400, 200)
position = display.center - Point(width / 2, height / 2)
// 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 do not prevent user interactions with the rest of the app.
Popup relative positioning
There are many use cases where a popup's position needs to be tied to another View
when it is shown. This is the case for drop down menus, hover callouts, tool tips, etc.. This is easy to achieve as well using the PopupManager
. You simply use the variant of show
that takes a relativeTo
value. The manager will ensure the popup is positioned relative to the given View
based on the constraints specified.
This app shows a popup that is positioned relative to the blue rectangle. Move the rectangle around while the popup is visible to see how it works.
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.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 {
size = Size(300, 200)
position = display.center - Point(width / 2, height / 2)
// show popup when blue view clicked
pointerChanged += clicked {
popups.show(popup, relativeTo = this) { popup, blueView ->
// size / position popup
(popup.top eq blueView.y ) .. Strong
(popup.left eq blueView.right + 10) .. Strong
(popup.bottom lessEq parent.bottom - 5) .. 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
Modals are popups that can disable further interaction with the app (except for subsequent modals of course). They are useful when some user input is needed before proceeding. You can create a modal using any View
(just like a popup) using the ModalManager
. In fact, the ModalManager
uses the PopupManager
internally to manage modals.
You must include the ModalModule
(Web, Desktop) in your application in order 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
}
Doodle uses opt-in modules like this to improve bundle size.
Modals are asynchronous and return a value <T>
. This means you have to call them from a CoroutineContext
and you cannot get their result until they complete. This makes them ideal for user input.
You launch a modal by invoking the ModalManager
with a lambda that returns a Modal
<T>
or RelativeModal
<T>
. The type used in the return determines the value the modal will complete with. Completion is handled by calling the completed
method provided 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
}
This app shows a simple modal that drops down from the top and requires the user to dismissing it. This modal returns Unit
as it's result when the user clicks '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.core.then
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.Dimension.Height
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.centerX eq parent.centerX
it.centerY eq lerp(-it.height.readOnly / 2, parent.centerY.readOnly, layoutProgress)
}
}
}
}
//sampleEnd
display += clickMe
display.fill(White.paint)
display.layout = constrain(display.first(), fill)
}
}
private fun createPopup(completed: (Unit) -> Unit) = object: View() {
init {
width = 300.0
clipCanvasToBounds = false
children += Label("Thanks for clicking. Now please press Ok to acknowledge.").apply {
fitText = setOf(Height)
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
label.height.preserve
button.top eq label.bottom + 20
button.width eq parent.width / 4
button.height eq 30
button.centerX eq parent.centerX
}.then {
height = children.last().bounds.bottom + 20
}
}
override fun render(canvas: Canvas) {
canvas.outerShadow(blurRadius = 10.0, color = Black opacity 0.05f) {
canvas.rect(bounds.atOrigin, radius = 10.0, fill = White.paint)
}
}
}
override fun shutdown() {}
}
This shows how you might present a choice using a modal. Here we ask the user to select between two Color
s. This modal is centered (the default), it uses a different background treatment, and has a different animation when you click outside it.
- 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.core.then
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.Dimension.Height
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)
}
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 {
width = 300.0
clipCanvasToBounds = false
children += Label("Which pill?").apply {
fitText = setOf(Height)
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
label.height.preserve
red.top eq label.bottom + 20
red.width eq parent.width / 4
red.height eq 30
red.right eq parent.centerX - 5
blue.top eq red.top
blue.width eq red.width
blue.height eq red.height
blue.left eq red.right + 10
}.then {
height = children.last().bounds.bottom + 20
}
}
override fun render(canvas: Canvas) {
canvas.outerShadow(blurRadius = 10.0, color = Black opacity 0.05f) {
canvas.rect(bounds.atOrigin, radius = 10.0, fill = White.paint)
}
}
}
override fun shutdown() {}
}
Modal relative positioning
Modals can also be positioned in relation to a View
just like popups. Simple return a RelativeModal
instead when creating 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
}