commonMain.io.nacular.doodle.theme.basic.tabbedpanel.BasicTabbedPanelBehavior.kt Maven / Gradle / Ivy
package io.nacular.doodle.theme.basic.tabbedpanel
import io.nacular.doodle.accessibility.TabListRole
import io.nacular.doodle.accessibility.TabRole
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.invoke
import io.nacular.doodle.controls.panels.TabbedPanel
import io.nacular.doodle.controls.panels.TabbedPanelBehavior
import io.nacular.doodle.core.Layout
import io.nacular.doodle.core.PositionableContainer
import io.nacular.doodle.core.View
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.Gray
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.ColorPaint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.darker
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.event.PointerListener.Companion.on
import io.nacular.doodle.event.PointerMotionListener.Companion.dragged
import io.nacular.doodle.geometry.Path
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.path
import io.nacular.doodle.layout.Insets
import io.nacular.doodle.layout.constraints.Bounds
import io.nacular.doodle.layout.constraints.ConstraintDslContext
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.withSizeInsets
import io.nacular.doodle.system.Cursor.Companion.Grabbing
import io.nacular.doodle.theme.basic.ColorMapper
import io.nacular.doodle.utils.Completable
import io.nacular.doodle.utils.NoOpCompletable
import io.nacular.doodle.utils.addOrAppend
import io.nacular.doodle.utils.allCompleted
import io.nacular.doodle.utils.diff.Delete
import io.nacular.doodle.utils.diff.Differences
import io.nacular.doodle.utils.diff.Insert
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* Created by Nicholas Eddy on 3/14/19.
*/
public abstract class Tab(protected val role: TabRole = TabRole()): View(accessibilityRole = role) {
public abstract var index: Int
}
public open class BasicTab(
private val panel : TabbedPanel,
override var index : Int,
visualizer : ItemVisualizer,
private val radius : Double,
private val tabColor : Color,
private val move : (panel: TabbedPanel, tab: Int, by: Double) -> Unit,
private val cancelMove : (panel: TabbedPanel, tab: Int) -> Unit,
private var selectedColorMapper: ColorMapper,
private var hoverColorMapper : ColorMapper
): Tab() {
private var pointerOver = false
set(new) {
field = new
backgroundColor = when {
selected -> selectedColorMapper(tabColor)
new -> hoverColorMapper (tabColor)
else -> tabColor
}
}
private var pointerDown = false
private var initialPosition = null as Point?
private val selected get() = panel.selection == index
private var path: Path
private val selectionChanged = { _: TabbedPanel, old: Int?, new: Int? ->
backgroundColor = when {
old == index -> if (pointerOver) hoverColorMapper(tabColor) else null
new == index -> selectedColorMapper(tabColor)
else -> null
}
role.selected = new == index
}
public var cellAlignment: (ConstraintDslContext.(Bounds) -> Unit) = { it.centerY eq parent.centerY }
private fun constrainLayout(view: View) = constrain(view) { content ->
withSizeInsets(width = 4 * radius) {
cellAlignment(content.withOffset(left = 2 * radius))
}
}
init {
children += visualizer(panel[index]!!).also {
it.x = 2 * radius
}
layout = constrainLayout(children[0])
pointerChanged += on(
pressed = { event ->
pointerDown = true
panel.selection = index
initialPosition = toLocal(event.location, event.target)
event.consume()
},
entered = { if (!pointerOver) pointerOver = true },
exited = { event ->
pointerOver = when (val p = parent) {
null -> event.target.toAbsolute(event.location) in this@BasicTab
else -> p.toLocal(event.location, event.target) in this@BasicTab
}
},
released = {
if (pointerDown) {
cursor = null
pointerDown = false
cancelMove(panel, index)
}
initialPosition = null
}
)
pointerMotionChanged += dragged { event ->
initialPosition?.let {
val delta = (toLocal(event.location, event.target) - it).x
move(panel, index, delta)
cursor = Grabbing
event.consume()
}
}
boundsChanged += { _,_,_ -> path = updatePath() }
styleChanged += { rerender() }
if (selected) {
backgroundColor = selectedColorMapper(tabColor)
role.selected = true
}
path = updatePath()
}
override fun addedToDisplay() {
super.addedToDisplay()
panel.selectionChanged += selectionChanged
}
override fun removedFromDisplay() {
super.removedFromDisplay()
panel.selectionChanged -= selectionChanged
}
override fun render(canvas: Canvas) {
val selection = panel.selection
backgroundColor?.let {
canvas.path(path, ColorPaint(it))
} ?: when {
selection != null && (index > selection || index < selection - 1) -> {
canvas.line(Point(width - radius, radius), Point(width - radius, height - radius), Stroke(Gray))
}
else -> {}
}
}
override fun contains(point: Point): Boolean = super.contains(point) && when (val localPoint = toLocal(point, parent)) {
in Rectangle(radius, 0.0, width - 2 * radius, height) -> when (localPoint) {
in Rectangle( radius, 0.0, radius, radius) -> sqrt((Point( 2 * radius, radius) - localPoint).run { x * x + y * y }) <= radius
in Rectangle(width - 2 * radius, 0.0, radius, radius) -> sqrt((Point(width - 2 * radius, radius) - localPoint).run { x * x + y * y }) <= radius
else -> true
}
in Rectangle( 0.0, height - radius, radius, radius) -> sqrt((Point( 0.0, height - radius) - localPoint).run { x * x + y * y }) > radius
in Rectangle(width - radius, height - radius, radius, radius) -> sqrt((Point(width, height - radius) - localPoint).run { x * x + y * y }) > radius
else -> false
}
private fun updatePath() = path(Point(0.0, height)).
quadraticTo(Point(radius, height - radius), Point(radius, height)).
lineTo (Point(radius, radius )).
quadraticTo(Point(2 * radius, 0.0 ), Point(radius, 0.0 )).
lineTo (Point(width - 2 * radius, 0.0 )).
quadraticTo(Point(width - radius, radius ), Point(width - radius, 0.0 )).
lineTo (Point(width - radius, height - radius )).
quadraticTo(Point(width, height ), Point(width - radius, height)).
close ().also {
childrenClipPath = PolyClipPath(Rectangle(Point(2 * radius, 0.0), Size(max(0.0, width - 4 * radius), height)))
}
}
public interface TabProducer {
public val spacing : Double
public val tabHeight: Double
public operator fun invoke(panel: TabbedPanel, item: T, index: Int): Tab
}
public open class BasicTabProducer(override val tabHeight : Double = 40.0,
protected val tabRadius : Double = 10.0,
protected val tabColor : Color = Color(0xdee1e6u),
protected val selectedColorMapper: ColorMapper = { White },
protected val hoverColorMapper : ColorMapper = { it.darker(0.1f) }
): TabProducer {
override val spacing: Double = -2 * tabRadius
override fun invoke(panel: TabbedPanel, item: T, index: Int): BasicTab = BasicTab(
panel,
index,
panel.tabVisualizer,
tabRadius,
tabColor,
move,
cancelMove,
selectedColorMapper,
hoverColorMapper
).apply { size = Size(100.0, tabHeight) } // FIXME: use dynamic width
protected open val move: (TabbedPanel, Int, Double) -> Unit = { _,_,_ -> }
protected open val cancelMove: (TabbedPanel, Int) -> Unit = { _,_ -> }
}
private class TabLayout(private val minWidth: Double = 40.0, private val defaultWidth: Double = 200.0, private val spacing: Double = 0.0): Layout {
override fun layout(container: PositionableContainer) {
val maxLineWidth = max(0.0, container.width - container.insets.left - container.insets.right - (container.children.size - 1) * spacing)
var x = container.insets.left
val width = max(minWidth, min(defaultWidth, maxLineWidth / container.children.size))
container.children.filter { it.visible }.forEach { child ->
child.width = width
child.position = Point(x, container.insets.top)
x += width + spacing
}
}
}
public abstract class TabContainer(protected val role: TabListRole = TabListRole()): View(accessibilityRole = role) {
/**
* Called whenever the TabbedPanel's selection changes. This is an explicit API to ensure that
* behaviors receive the notification before listeners to [TabbedPanel.selectionChanged].
*
* @param panel with change
* @param newIndex of the selected item
* @param oldIndex of previously selected item
*/
public abstract fun selectionChanged(panel: TabbedPanel, newIndex: Int?, oldIndex: Int?)
/**
* Called whenever the items within the TabbedPanel change.
*
* @param panel with change
* @param differences
*/
public abstract fun itemsChanged(panel: TabbedPanel, differences: Differences)
/**
* Allows the container to learn which View will be associated with each tab.
*/
public abstract fun registerTab(panel: TabbedPanel, index: Int, tabPanel: View)
}
public open class SimpleTabContainer(panel: TabbedPanel, private val tabProducer: TabProducer): TabContainer() {
init {
children.addAll(panel.mapIndexed { index, item ->
tabProducer(panel, item, index)
})
insets = Insets(top = 10.0) // TODO: Make this configurable
layout = TabLayout(spacing = tabProducer.spacing)
}
override fun selectionChanged(panel: TabbedPanel, newIndex: Int?, oldIndex: Int?) {
oldIndex?.let { children.getOrNull(it) }?.let { it.zOrder = 0; it.rerender() }
newIndex?.let { children.getOrNull(it) }?.let { it.zOrder = 1; it.rerender() }
}
override fun itemsChanged(panel: TabbedPanel, differences: Differences) {
children.batch {
var index = 0
differences.computeMoves().forEach { difference ->
when (difference) {
is Delete -> difference.items.forEach {
when (val destination = difference.destination(of = it)){
null -> removeAt(index)
else -> addOrAppend(destination, removeAt(index))
}
}
is Insert -> difference.items.forEach {
if (difference.origin(of = it) == null) {
addOrAppend(index, tabProducer(panel, it, index))
++index
}
}
else -> { index += difference.items.size }
}
}
filterIsInstance>().forEachIndexed { i, tab ->
tab.index = i
}
}
}
override fun registerTab(panel: TabbedPanel, index: Int, tabPanel: View) {
children.getOrNull(index)?.let { tab ->
role[tab] = tabPanel
}
}
}
public open class AnimatingTabContainer(
private val panel : TabbedPanel,
private val tabProducer : TabProducer,
private val animateOpacity: (start: Float, end: Float, block: (progress: Float) -> Unit) -> Completable = { _,end,block -> NoOpCompletable.also { block(end) } },
private val animateTab : (distance: Double, block: (progress: Float) -> Unit) -> Completable = { _,block -> NoOpCompletable.also { block(1f) } }): SimpleTabContainer(panel, tabProducer) {
private val hoverColors = mutableMapOf()
private val moveAnimations = mutableMapOf()
private val colorAnimations = mutableMapOf()
private var movingFromIndex = null as Int?
private var movingToIndex = 0
init {
childrenChanged += { _,diffs ->
diffs.forEach {
if (it is Insert) {
it.items.filterIsInstance>().forEach { item ->
if (it.origin(of = item) == null) {
tagTab(panel, item)
}
}
}
}
}
children.filterIsInstance>().forEach { tagTab(panel, it) }
}
private fun tagTab(panel: TabbedPanel, tab: Tab) = tab.apply {
var pointerDown = false
var initialPosition = null as Point?
var pointerOver = false
pointerChanged += on(
pressed = { event ->
pointerDown = true
initialPosition = toLocal(event.location, event.target)
cleanupAnimation(tab)
},
entered = {
if (panel.selection != tab.index && !pointerOver) {
pointerOver = true
hoverColors[tab] = tab.backgroundColor
doAnimation(tab, 0f, 1f)
}
},
exited = { event ->
if (panel.selection != tab.index &&
when (val p = parent) {
null -> event.target.toAbsolute(event.location) !in tab
else -> p.toLocal(event.location, event.target) !in tab
}) {
pointerOver = false
tab.backgroundColor = hoverColors[tab]
doAnimation(tab, 1f, 0f)
}
},
released = {
if (pointerDown) {
pointerDown = false
completeMove(panel, index)
}
initialPosition = null
}
)
pointerMotionChanged += dragged { event ->
initialPosition?.let {
val delta = (toLocal(event.location, event.target) - it).x
movingFromIndex?.let {
if (it != index) {
children.filterIsInstance>().getOrNull(it)?.let {
stopMoveNow(panel, it)
}
}
}
movingFromIndex = index
move(panel, index, delta)
cursor = Grabbing
event.consume()
}
}
}
private fun cleanupAnimation(tab: View) {
colorAnimations[tab]?.let {
it.cancel()
colorAnimations.remove(tab)
}
}
// FIXME: Handle right-left when tab is left-right
private fun move(panel: TabbedPanel, movingIndex: Int, delta: Double) {
children.getOrNull(movingIndex)?.apply {
zOrder = 1
val translateX = transform.translateX
val sanitizedDelta = min(max(delta, 0 - (x + translateX)), panel.width - width - (x + translateX))
transform *= Identity.translate(sanitizedDelta)
val adjustWidth = width + tabProducer.spacing
children.forEachIndexed { index, tab ->
if (tab != this) {
val targetBounds = tab.bounds
val value = when (targetBounds.x + tab.transform.translateX + targetBounds.width / 2) {
in x + translateX + sanitizedDelta .. x + translateX -> adjustWidth
in x + translateX .. x + translateX + sanitizedDelta -> -adjustWidth
in bounds.right + translateX .. bounds.right + translateX + sanitizedDelta -> -adjustWidth
in bounds.right + translateX + sanitizedDelta .. bounds.right + translateX -> adjustWidth
else -> null
}
value?.let {
val oldTransform = tab.transform
val minViewX = if (index > movingIndex) tab.x - adjustWidth else tab.x
val maxViewX = minViewX + adjustWidth
val offset = tab.x + tab.transform.translateX
val translate = min(max(value, minViewX - offset), maxViewX - offset)
moveAnimations[tab]?.cancel()
moveAnimations[tab] = animateTab(abs(translate)) {
tab.transform = oldTransform.translate(translate * it)
}
}
} else {
moveAnimations.remove(tab)?.cancel()
}
}
}
}
private fun completeMove(panel: TabbedPanel, movingIndex: Int) {
children.filterIsInstance>().getOrNull(movingIndex)?.apply {
val myOffset = x + transform.translateX
movingToIndex = if (myOffset >= children.last().x) children.size - 1 else movingIndex
var targetBounds = bounds
run loop@ {
when {
transform.translateX > 0 -> {
for (index in children.lastIndex downTo 0) {
val tab = children[index]
val targetMiddle = tab.x + tab.transform.translateX + tab.width / 2
if (myOffset + width > targetMiddle) {
movingToIndex = index
targetBounds = children[movingToIndex].bounds
return@loop
}
}
}
else -> {
children.forEachIndexed { index, tab ->
val targetMiddle = tab.x + tab.transform.translateX + tab.width / 2
if (myOffset < targetMiddle) {
movingToIndex = index
targetBounds = children[movingToIndex].bounds
return@loop
}
}
}
}
}
val oldTransform = transform
val distance = when {
index < movingToIndex -> targetBounds.right - width - myOffset
else -> targetBounds.x - myOffset
}
moveAnimations[this]?.cancel()
moveAnimations[this] = animateTab(abs(distance)) {
transform = oldTransform.translate(distance * it)
}
// wait until all outstanding animations are done before cleaning up
moveAnimations.values.allCompleted {
stopMoveNow(panel, this)
}
}
}
private fun stopMoveNow(panel: TabbedPanel, tab: Tab<*>) {
with(tab) {
zOrder = 0
if (index != movingToIndex) {
panel[index]?.let { panel.move(it, to = movingToIndex) }
}
children.forEach {
moveAnimations.remove(it)?.cancel()
it.transform = Identity
}
movingFromIndex = null
}
}
private fun doAnimation(tab: Tab, start: Float, end: Float) {
cleanupAnimation(tab)
val tabColor = tab.backgroundColor
colorAnimations[tab] = animateOpacity(start, end) {
tab.backgroundColor = tabColor?.opacity(it)
tab.rerenderNow()
}.apply {
completed += {
colorAnimations.remove(tab)
if (tab.backgroundColor?.opacity == 0f) {
tab.backgroundColor = null
}
}
}
}
}
public typealias TabContainerFactory = (TabbedPanel, TabProducer) -> TabContainer
public open class BasicTabbedPanelBehavior(
private val tabProducer : TabProducer,
private val backgroundColor: Color = Color(0xdee1e6u),
private val tabContainer : TabContainerFactory = { panel, producer -> SimpleTabContainer(panel, producer) }): TabbedPanelBehavior() {
override fun install(view: TabbedPanel) {
view.apply {
val tabContainer = tabContainer(view, tabProducer)
children += tabContainer
view.forEachIndexed { index, item ->
children.add(view.visualizer(item).apply {
visible = item == view.selectedItem
tabContainer.registerTab(view, index, this)
})
}
layout = object: Layout {
override fun layout(container: PositionableContainer) {
container.children.forEachIndexed { index, view ->
view.bounds = when (index) {
0 -> Rectangle(container.width, tabProducer.tabHeight + 10)
else -> Rectangle(size = container.size).inset(Insets(top = tabProducer.tabHeight + 10))
}
}
}
}
}
}
override fun uninstall(view: TabbedPanel) {
view.apply {
children.clear()
layout = null
}
}
override fun selectionChanged(panel: TabbedPanel, new: T?, newIndex: Int?, old: T?, oldIndex: Int?) {
oldIndex?.let {
panel.children.getOrNull(it + 1)?.visible = false
}
newIndex?.let {
panel.children.getOrNull(it + 1)?.visible = true
}
@Suppress("UNCHECKED_CAST")
(panel.children.firstOrNull() as? TabContainer)?.selectionChanged(panel, newIndex, oldIndex)
}
override fun itemsChanged(panel: TabbedPanel, differences: Differences) {
@Suppress("UNCHECKED_CAST")
(panel.children.firstOrNull() as? TabContainer)?.apply {
itemsChanged(panel, differences)
}
var index = 0
differences.computeMoves().forEach {
when (it) {
is Delete -> {
it.items.forEach { item ->
when (val destination = it.destination(of = item)) {
null -> panel.children.removeAt(index + 1)
else -> panel.children.move(panel.children[index + 1], destination + 1)
}
}
}
is Insert -> {
it.items.forEach { item ->
if (it.origin(of = item) == null) {
panel.children.addOrAppend(index + 1, panel.visualizer(item))
++index
}
}
}
else -> { index += it.items.size }
}
}
}
override fun render(view: TabbedPanel, canvas: Canvas) {
canvas.rect(view.bounds.atOrigin, backgroundColor.paint)
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy