Notifications can be hidden (#9108)

* Some linting around Notification UI

* Hide-able NotificationsScroll with restore button
This commit is contained in:
SomeTroglodyte 2023-04-08 20:40:48 +02:00 committed by GitHub
parent 6b084f0e6b
commit 04d4def24f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 298 additions and 81 deletions

View File

@ -49,6 +49,11 @@ enum class NotificationCategory{
War,
Religion,
Cities
;
companion object {
fun safeValueOf(name: String): NotificationCategory? =
values().firstOrNull { it.name == name }
}
}
/**
@ -70,17 +75,18 @@ open class Notification() : IsPartOfGameInfoSerialization {
this.category = category.name
}
fun addNotificationIcons(ruleset: Ruleset, iconSize: Float, table: Table) {
fun addNotificationIconsTo(table: Table, ruleset: Ruleset, iconSize: Float) {
if (icons.isEmpty()) return
for (icon in icons.reversed()) {
val image: Actor = when {
ruleset.technologies.containsKey(icon) -> ImageGetter.getTechIconPortrait(icon, iconSize)
ruleset.nations.containsKey(icon) -> ImageGetter.getNationPortrait(
ruleset.nations[icon]!!,
iconSize
)
ruleset.units.containsKey(icon) -> ImageGetter.getUnitIcon(icon)
else -> ImageGetter.getImage(icon)
ruleset.technologies.containsKey(icon) ->
ImageGetter.getTechIconPortrait(icon, iconSize)
ruleset.nations.containsKey(icon) ->
ImageGetter.getNationPortrait(ruleset.nations[icon]!!, iconSize)
ruleset.units.containsKey(icon) ->
ImageGetter.getUnitIcon(icon)
else ->
ImageGetter.getImage(icon)
}
table.add(image).size(iconSize).padRight(5f)
}

View File

@ -7,8 +7,14 @@ import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.utils.Align
import com.unciv.ui.components.extensions.center
class IconCircleGroup(size: Float, val actor: Actor, resizeActor: Boolean = true,
color: Color = Color.WHITE, circleImage:String = "OtherIcons/Circle"): Group(){
class IconCircleGroup(
size: Float,
val actor: Actor,
resizeActor: Boolean = true,
color: Color = Color.WHITE,
circleImage: String = "OtherIcons/Circle"
): Group() {
val circle = ImageGetter.getImage(circleImage).apply {
setSize(size, size)
setColor(color)
@ -24,5 +30,5 @@ class IconCircleGroup(size: Float, val actor: Actor, resizeActor: Boolean = true
addActor(actor)
}
override fun draw(batch: Batch?, parentAlpha: Float) = super.draw(batch, parentAlpha*color.a)
override fun draw(batch: Batch?, parentAlpha: Float) = super.draw(batch, parentAlpha * color.a)
}

View File

@ -67,6 +67,7 @@ enum class EmpireOverviewCategories(
Notifications("OtherIcons/Notifications", 'N', Align.top) {
override fun createTab(viewingPlayer: Civilization, overviewScreen: EmpireOverviewScreen, persistedData: EmpireOverviewTabPersistableData?) =
NotificationsOverviewTable(viewingPlayer, overviewScreen, persistedData)
override fun showDisabled(viewingPlayer: Civilization) = viewingPlayer.notifications.isEmpty() && viewingPlayer.notificationsLog.isEmpty()
}
;

View File

@ -42,10 +42,7 @@ class NotificationsOverviewTable(
private val notificationLog = viewingPlayer.notificationsLog
private val notificationTable = Table(BaseScreen.skin)
private val scaleFactor = 0.3f
private val inverseScaleFactor = 1f / scaleFactor
private val maxWidthOfStage = 0.333f
private val maxEntryWidth = worldScreen.stage.width * maxWidthOfStage * inverseScaleFactor
private val maxEntryWidth = worldScreen.stage.width - 20f
val iconSize = 20f
@ -101,7 +98,7 @@ class NotificationsOverviewTable(
notification.action?.execute(worldScreen)
}
notification.addNotificationIcons(worldScreen.gameInfo.ruleset, iconSize, notificationTable)
notification.addNotificationIconsTo(notificationTable, worldScreen.gameInfo.ruleset, iconSize)
turnTable.add(notificationTable).padTop(5f)
turnTable.padTop(20f).row()

View File

@ -1,9 +1,14 @@
package com.unciv.ui.screens.worldscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Container
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable
import com.badlogic.gdx.utils.Align
import com.unciv.logic.civilization.Notification
import com.unciv.logic.civilization.NotificationCategory
@ -12,10 +17,18 @@ import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.WrappableLabel
import com.unciv.ui.components.extensions.onClick
import com.unciv.ui.components.extensions.toLabel
import kotlin.math.min
import com.unciv.ui.components.extensions.packIfNeeded
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.components.AutoScrollPane as ScrollPane
/*TODO
* Some persistence - over game or over settings?
* Check state after Undo
* Idea: Blink the button when new notifications while "away"
* Idea: The little "1" on the bell - remove and draw actual count
*/
class NotificationsScroll(
private val worldScreen: WorldScreen
) : ScrollPane(null) {
@ -26,107 +39,301 @@ class NotificationsScroll(
const val inverseScaleFactor = 1f / scaleFactor
/** Limit width by wrapping labels to this percentage of the stage */
const val maxWidthOfStage = 0.333f
/** Logical size of the notification icons - note font size is coded separately */
/** Logical size of the notification icons */
const val iconSize = 30f
/** Logical font size used in notification and category labels */
const val fontSize = 30
/** This is the spacing between categories and also the spacing between the next turn button and the first header */
const val categoryTopPad = 15f
/** Spacing between rightmost Label edge and right Screen limit */
const val rightPadToScreenEdge = 10f
/** Extra spacing between the outer edges of the category header decoration lines and
* the left and right edges of the widest notification label - this is the background's
* edge radius and looks (subjectively) nice. */
const val categoryHorizontalPad = 13f
/** Size of the restore button */
const val restoreButtonSize = 42f
/** Distance of restore button to TileInfoTable and screen edge */
const val restoreButtonPad = 12f
}
private var notificationsHash: Int = 0
private var notificationsTable = Table()
private var topSpacerCell: Cell<Actor?>? = null
private var bottomSpacerCell: Cell<Actor?>? = null
private val maxEntryWidth = worldScreen.stage.width * maxWidthOfStage * inverseScaleFactor
/** For category header decoration lines */
private val minCategoryLineWidth = worldScreen.stage.width * 0.075f
/** Show restoreButton when less than this much of the pane is left */
private val scrolledAwayEpsilon = minCategoryLineWidth
private val restoreButton = RestoreButton()
init {
actor = notificationsTable.right()
touchable = Touchable.childrenOnly
setOverscroll(false, true)
setScale(scaleFactor)
height = worldScreen.stage.height * inverseScaleFactor
}
/** Access to hidden "state" - writing it will ensure this is fully visible or hidden and the
* restore button shown as needed - with animation. */
var isHidden: Boolean
get () = scrollX <= scrolledAwayEpsilon
set(value) {
restoreButton.blocked = false
scrollX = if (value) 0f else width
}
/**
* Update widget contents if necessary and recalculate layout
* Update widget contents if necessary and recalculate layout.
*
* The 'covered' parameters are used to make sure we can scroll up or down far enough so the bottom or top entry is visible.
*
* @param notifications Data to display
* @param maxNotificationsHeight Total height in world screen coordinates
* @param tileInfoTableHeight Height of the portion that may be covered on the bottom - make sure we can scroll up far enough so the bottom entry is visible above this
* @param coveredNotificationsTop Height of the portion that may be covered on the bottom w/o any padding
* @param coveredNotificationsBottom Height of the portion that may be covered on the bottom w/o any padding
*/
internal fun update(
notifications: MutableList<Notification>,
maxNotificationsHeight: Float
notifications: List<Notification>,
coveredNotificationsTop: Float,
coveredNotificationsBottom: Float
) {
// Initial scrollX should scroll all the way to the right - the setter automatically clamps
val previousScrollX = if (notificationsTable.hasChildren()) scrollX else Float.MAX_VALUE
val previousScrollY = scrollY
updateContent(notifications)
updateLayout(maxNotificationsHeight)
restoreButton.blocked = true // For the update, since ScrollPane may layout and change scrollX
if (updateContent(notifications, coveredNotificationsTop, coveredNotificationsBottom)) {
updateLayout()
if (notifications.isEmpty())
scrollX = previousScrollX
else
isHidden = false
} else {
updateSpacers(coveredNotificationsTop, coveredNotificationsBottom)
scrollX = previousScrollX
}
scrollY = previousScrollY
updateVisualScroll()
restoreButton.blocked = false
// Do the positioning here since WorldScreen may also call update when just its geometry changed
setPosition(stage.width - width * scaleFactor, 0f)
restoreButton.setPosition(
stage.width - restoreButtonPad,
coveredNotificationsBottom + restoreButtonPad,
Align.bottomRight)
}
private fun updateContent(notifications: MutableList<Notification>) {
// no news? - keep our list as it is, especially don't reset scroll position
private fun updateContent(
notifications: List<Notification>,
coveredNotificationsTop: Float,
coveredNotificationsBottom: Float
): Boolean {
// no news? - keep our list as it is
val newHash = notifications.hashCode()
if (notificationsHash == newHash) return
if (notificationsHash == newHash) return false
notificationsHash = newHash
notificationsTable.clearChildren()
notificationsTable.clear()
notificationsTable.pack() // forget last width!
if (notifications.isEmpty()) return true
val reversedNotifications = notifications.asReversed().toList() // toList to avoid concurrency problems
for (category in NotificationCategory.values()){
val categoryHeaders = mutableListOf<CategoryHeader>()
val itemWidths = mutableListOf<Float>()
val categoryNotifications = reversedNotifications.filter { it.category == category.name }
if (categoryNotifications.isEmpty()) continue
topSpacerCell = notificationsTable.add()
.height(coveredNotificationsTop * inverseScaleFactor)
notificationsTable.row()
val backgroundDrawable = BaseScreen.skinStrings.getUiBackground("WorldScreen/Notification", BaseScreen.skinStrings.roundedEdgeRectangleShape)
if (category != NotificationCategory.General)
notificationsTable.add(Table().apply {
add(ImageGetter.getWhiteDot()).minHeight(2f).width(worldScreen.stage.width/8)
add(Table().apply {
background = backgroundDrawable
add(ColorMarkupLabel(category.name, Color.BLACK, fontSize = 30))
}).pad(3f)
add(ImageGetter.getWhiteDot()).minHeight(2f).width(worldScreen.stage.width/8)
}).row()
val backgroundDrawable = BaseScreen.skinStrings.getUiBackground("WorldScreen/Notification", BaseScreen.skinStrings.roundedEdgeRectangleShape)
val orderedNotifications = notifications.asReversed()
.groupBy { NotificationCategory.safeValueOf(it.category) ?: NotificationCategory.General }
.toSortedMap() // This sorts by Category ordinal, so far intentional - the order of the grouped lists are unaffected
for ((category, categoryNotifications) in orderedNotifications) {
if (category == NotificationCategory.General)
notificationsTable.add().padTop(categoryTopPad).row() // Make sure category w/o header gets same spacing
else {
val header = CategoryHeader(category, backgroundDrawable)
categoryHeaders.add(header)
notificationsTable.add(header).right().row()
}
for (notification in categoryNotifications) {
val listItem = Table()
listItem.background = backgroundDrawable
val labelWidth = maxEntryWidth - iconSize * notification.icons.size - 10f
val label = WrappableLabel(notification.text, labelWidth, Color.BLACK, 30)
label.setAlignment(Align.center)
if (label.prefWidth > labelWidth * scaleFactor) { // can't explain why the comparison needs scaleFactor
label.wrap = true
listItem.add(label).maxWidth(label.optimizePrefWidth()).padRight(10f)
} else {
listItem.add(label).padRight(10f)
}
notification.addNotificationIcons(worldScreen.gameInfo.ruleset, iconSize, listItem)
// using a large click area with no gap in between each message item.
// this avoids accidentally clicking in between the messages, resulting in a map click
val clickArea = Table().apply {
add(listItem).pad(3f)
touchable = Touchable.enabled
onClick { notification.action?.execute(worldScreen) }
}
notificationsTable.add(clickArea).right().row()
val item = ListItem(notification, backgroundDrawable)
itemWidths.add(item.itemWidth)
notificationsTable.add(item).right().row()
}
}
notificationsTable.pack() // needed to get height - prefHeight is set and close but not quite the same value
// I think average looks better than max or non-equalized
// (Note: if itemWidths ever were empty, average would return Double.NaN!)
val newHeaderWidth = itemWidths.average().toFloat()
for (header in categoryHeaders) {
header.increaseWidthTo(newHeaderWidth)
}
bottomSpacerCell = notificationsTable.add()
.height(coveredNotificationsBottom * inverseScaleFactor).expandY()
notificationsTable.row()
return true
}
private fun updateLayout(maxNotificationsHeight: Float) {
val newHeight = min(notificationsTable.height, maxNotificationsHeight * inverseScaleFactor)
private inner class CategoryHeader(
category: NotificationCategory,
backgroundDrawable: NinePatchDrawable
) : Table() {
private val leftLineCell: Cell<Image>
private val rightLineCell: Cell<Image>
private val captionWidth: Float
pack()
height = newHeight // after this, maxY is still incorrect until layout()
init {
touchable = Touchable.enabled // stop clicks going through to map
add().padTop(categoryTopPad).colspan(3).row()
leftLineCell = add(ImageGetter.getWhiteDot())
.minHeight(2f).width(minCategoryLineWidth)
add(Table().apply {
background = backgroundDrawable
val label = ColorMarkupLabel(category.name, Color.BLACK, fontSize = fontSize)
add(label)
captionWidth = prefWidth // of this wrapper including background rims
captionWidth
}).pad(3f)
rightLineCell = add(ImageGetter.getWhiteDot())
.minHeight(2f).width(minCategoryLineWidth)
.padRight(categoryHorizontalPad + rightPadToScreenEdge)
}
/** Equalizes width by adjusting length of the decoration lines.
* Does nothing if that would leave the lines shorter than [minCategoryLineWidth].
* @param newWidth maximum notification label width including background padding
*/
fun increaseWidthTo(newWidth: Float) {
val lineLength = (newWidth - captionWidth - 6f) * 0.5f - categoryHorizontalPad
if (lineLength <= minCategoryLineWidth) return
leftLineCell.width(lineLength)
rightLineCell.width(lineLength)
}
}
private inner class ListItem(
notification: Notification,
backgroundDrawable: NinePatchDrawable
) : Table() {
/** Returns width of the visible Notification including background padding but not
* including outer touchable area padding */
val itemWidth: Float
init {
val listItem = Table()
listItem.background = backgroundDrawable
val maxLabelWidth = maxEntryWidth - (iconSize + 5f) * notification.icons.size - 10f
val label = WrappableLabel(notification.text, maxLabelWidth, Color.BLACK, fontSize)
label.setAlignment(Align.center)
if (label.prefWidth > maxLabelWidth * scaleFactor) { // can't explain why the comparison needs scaleFactor
label.wrap = true
listItem.add(label).maxWidth(label.optimizePrefWidth()).padRight(10f)
} else {
listItem.add(label).padRight(10f)
}
notification.addNotificationIconsTo(listItem, worldScreen.gameInfo.ruleset, iconSize)
itemWidth = listItem.prefWidth // includes the background NinePatch's leftWidth+rightWidth
// using a large click area with no gap in between each message item.
// this avoids accidentally clicking in between the messages, resulting in a map click
add(listItem).pad(3f, 3f, 3f, rightPadToScreenEdge)
touchable = Touchable.enabled
onClick { notification.action?.execute(worldScreen) }
}
}
/** Should only be called when updateContent just rebuilt the notificationsTable */
private fun updateLayout() {
// size this ScrollPane to content
width = notificationsTable.packIfNeeded().width
// Allow scrolling content out of the screen to the right
topSpacerCell?.width(2 * width)
notificationsTable.invalidate() // topSpacerCell.width _should_ have done that
notificationsTable.pack() // again so new topSpacerCell size is taken into account
layout() // This calculates maxXY so setScrollXY clamps correctly
}
private fun updateSpacers(coveredNotificationsTop: Float, coveredNotificationsBottom: Float) {
topSpacerCell?.height(coveredNotificationsTop * inverseScaleFactor)
bottomSpacerCell?.height(coveredNotificationsBottom * inverseScaleFactor)
layout()
}
fun setTopRight (right: Float, top: Float) {
setPosition(right - width * scaleFactor, top - height * scaleFactor)
override fun scrollX(pixelsX: Float) {
super.scrollX(pixelsX)
if (maxX < 5f) return
restoreButton.checkScrollX(pixelsX)
}
inner class RestoreButton : Container<IconCircleGroup>() {
var blocked = true
var active = false
init {
actor = ImageGetter.getImage("OtherIcons/Notifications")
.surroundWithCircle(restoreButtonSize * 0.9f, color = BaseScreen.skinStrings.skinConfig.baseColor)
.surroundWithCircle(restoreButtonSize, resizeActor = false)
size(restoreButtonSize)
color = color.cpy() // So we don't mutate a skin element while fading
color.a = 0f // for first fade-in
onClick {
scrollX = this@NotificationsScroll.width
hide()
}
pack() // `this` needs to adopt the size of `actor`, won't happen automatically (surprisingly)
}
fun show() {
active = true
blocked = false
if (parent == null)
worldScreen.stage.addActor(this)
addAction(Actions.fadeIn(1f))
}
fun hide() {
clearActions()
blocked = false
if (parent == null) return
addAction(
Actions.sequence(
Actions.fadeOut(0.333f),
Actions.run {
remove()
active = false
}
)
)
}
fun checkScrollX(scrollX: Float) {
if (blocked) return
if (active && scrollX >= scrolledAwayEpsilon * 2)
hide()
if (!active && scrollX <= scrolledAwayEpsilon)
show()
}
override fun act(delta: Float) {
// Actions are blocked while update() is rebuilding the UI elements - to be safe from unexpected state changes
if (!blocked)
super.act(delta)
}
}
}

View File

@ -321,7 +321,7 @@ class WorldScreen(
// and we don't get any silly concurrency problems!
private fun update() {
if(uiEnabled){
if (uiEnabled) {
displayTutorialsOnUpdate()
bottomUnitTable.update()
@ -397,10 +397,10 @@ class WorldScreen(
updateGameplayButtons()
val maxNotificationsHeight = statusButtons.y -
(if (game.settings.showMinimap) minimapWrapper.height else 0f) - bottomTileInfoTable.height - 5f
notificationsScroll.update(viewingCiv.notifications, maxNotificationsHeight)
notificationsScroll.setTopRight(stage.width - 10f, statusButtons.y - 5f)
val coveredNotificationsTop = stage.height - statusButtons.y
val coveredNotificationsBottom = bottomTileInfoTable.height +
(if (game.settings.showMinimap) minimapWrapper.height else 0f)
notificationsScroll.update(viewingCiv.notifications, coveredNotificationsTop, coveredNotificationsBottom)
val posZoomFromRight = if (game.settings.showMinimap) minimapWrapper.width
else bottomTileInfoTable.width