android - 如何比较多个时间范围并在 Android Compose 中并排显示它们

标签 android kotlin android-jetpack-compose schedule

我正在尝试在 android compose 中使用自定义布局构建自定义时间表。 我正在使用包装对象来处理事件的数据。每个事件都有一个开始和结束 LocalDateTime。 当事件不发生冲突时,代码可以正常工作,但我需要一种并排显示多个事件的方法。 你有什么想法吗?

可组合代码。

@Composable
fun BasicSchedule(
    bookings: List<Booking> = mainViewModel.listOfBookings,
    modifier: Modifier,
    currentSelectedDay: LocalDate = mainViewModel.currentSelectedDay,
    hourHeight: Dp = 64.dp,
) {
    val dividerColor = if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray
    var height = 0

    Layout(content = {
        bookings.sortedBy(Booking::startTimestamp).forEach { booking ->
            if (mainViewModel.currentSelectedDay == booking.startTimestamp.toJavaLocalDateTime()
                    .toLocalDate()
            ) {
                Box(modifier = Modifier.eventData(booking)) {
                    BookingViewComposable(booking = booking)
                }
            }
        }
    }, modifier = modifier
        .fillMaxSize()
        .drawBehind {
            repeat(23) {
                drawLine(
                    dividerColor,
                    start = Offset(0f, (it + 1) * hourHeight.toPx()),
                    end = Offset(size.width, (it + 1) * hourHeight.toPx()),
                    strokeWidth = 1.dp.toPx()
                )
            }
        }
        .pointerInput(Unit) {
            detectTapGestures(onLongPress = { offset ->
                mainViewModel.newBookingStartTime = offset.y
                    .div(height / 24)
                    .roundToInt()
                mainViewModel.createNewBookingDialogOpen = true
            })
        }) { measureables, constraints ->
        height = hourHeight.roundToPx() * 24
        val placeablesWithEvents = measureables.map { measurable ->
            val booking = measurable.parentData as Booking
            val eventDurationMinutes = ChronoUnit.MINUTES.between(
                booking.startTimestamp.toJavaLocalDateTime(),
                booking.endTimestamp.toJavaLocalDateTime()
            )
            val eventHeight = ((eventDurationMinutes / 60f) * hourHeight.toPx()).roundToInt()
            val placeable = measurable.measure(Constraints.fixed(constraints.maxWidth - 30, eventHeight))

            Pair(placeable, booking)
        }
        
        layout(constraints.maxWidth, height) {
            placeablesWithEvents.forEach { (placeable, booking) ->
                val eventOffsetMinutes = ChronoUnit.MINUTES.between(
                    LocalTime.MIN, booking.startTimestamp.toJavaLocalDateTime()
                )
                val eventY = ((eventOffsetMinutes / 60f) * hourHeight.toPx()).roundToInt()
                val eventX = 0

                placeable.place(eventX, eventY)
            }
        }
    }
}

how it is How it should be

你有什么建议吗?

我尝试将 LocalDateTime 对象相互进行比较,这是可能的,但我不知道如何使用多个 DateTimes 来做到这一点。

最佳答案

要为每个广义边缘情况正确地堆叠事件将需要一个复杂的实现,因此这里有一个“简化”的实现(用引号引起来,因为这个问题的解决方案仍然很重要),它对于以下情况足够有效:没有太多重叠的事件。这种简化的实现不会平均分配宽度,只是让最后一个元素在某些可能进行宽度重新分配的边缘情况下占据一些额外的宽度。

首先,我创建了一个用于遍历类似图形的数据结构的辅助函数,因为下面的代码以各种方式多次使用它。它返回一个Sequence,可以对a进行广度优先或深度优先遍历 子图/子树并按照访问顺序生成节点。在实现中我只使用了呼吸优先遍历,但在遍历函数中我也保留了深度优先模式。

/**
 * Traverses the graph-like data structure starting from the receiver.
 * 
 * @param depthFirst if true, the traversal is depth-first, otherwise breadth-first
 * @param nextIterator a function that returns an iterator over the next nodes to visit
 * @return a sequence of nodes in the order they are visited
 */
fun <T> T.traverse(depthFirst: Boolean = false, nextIterator: (T) -> Iterator<T>): Sequence<T> {
    return sequence {
        val current = this@traverse
        if (!depthFirst) yield(current)

        val iterator = nextIterator(current)
        while (iterator.hasNext()) {
            yieldAll(iterator.next().traverse(depthFirst, nextIterator))
        }

        if (depthFirst) yield(current)
    }
}

对于数据结构,我使用了一个 Node 类,它可以保存一个元素,知道它自己在图中的深度,知道它所属的图的总深度,并且可以保存对两者的引用上一个和下一个节点。

class Node(
        val element: T,
        val currentDepth: Int,
        var totalDepth: Int = currentDepth + 1,
        val prev: MutableList<Node> = mutableListOf(),
        val next: MutableList<Node> = mutableListOf(),
    ) {
        var nextDepth: Node? = null
        var visualDebug = ""
    }

我还在代码中留下了您可以在上面的代码中看到的 visualDebug 字段。此调试信息显示哪些元素(事件)受到算法不同部分的影响。现在,代码只是根据下面描述的情况将 visualDebug 设置为数字 1-4,标记为 (1.) - (4.),但如果您想探索该算法的工作原理,您可以在算法代码内的各个步骤中将自己的调试信息添加到此字段。您只需更改本文末尾提供的演示代码中的 showVisualDebug 值即可启用或禁用此额外的调试信息和 this UI should show up .

// set to true to see some debug info directly on each event
const val showVisualDebug = true

我实现的简化算法如下:

  • 使用比较器对元素进行排序,这进一步简化了已经简化的方法
val sortedElements = elements.sortedWith(comparator)
  • 创建一个空列表来保存每个图表最顶部(最左侧)的非重叠节点
val nonOverlappingNodes = mutableListOf<Node>()
  • 迭代排序后的元素并将它们添加到类似图形的数据结构中。首先找到迄今为止创建的最后一个图中的第一个重叠节点。
sortedElements.forEach { elementToInsert ->
    val currentOverlappingWith = { node: Node -> areOverlapping(node.element, elementToInsert) }

    val last = nonOverlappingNodes.lastOrNull()

    val firstOverlap = last
        ?.traverse { it.next.iterator() }
        ?.firstOrNull(currentOverlappingWith)
    
    // ...
}
  • 如果元素:
    • (1.) 不与图中的任何节点重叠,通过使用深度为 0 的该元素创建一个新节点来启动一个新图
if (firstOverlap == null) {
    // inserting a new non-overlapping node
    val node = Node(elementToInsert, currentDepth = 0)
    node.visualDebug = "1"
    nonOverlappingNodes.add(node)
}
  • (2.) 与深度不为 0 的节点重叠,在深度 0 处创建一个新节点并将其连接到重叠节点
if (firstOverlap == null) { /* ... */ }
else if (firstOverlap.currentDepth > 0) {
    val node = Node(elementToInsert, currentDepth = 0, totalDepth = firstOverlap.totalDepth)
    node.visualDebug = "2"

    firstOverlap.prev.add(node)
    node.next.add(firstOverlap)
    node.nextDepth = firstOverlap

    // inserting a new top node at depth 0 that has an overlap deeper down the hierarchy
    nonOverlappingNodes.add(node)
}
  • (3.) 与深度为 0 的节点重叠,找到该图中的第一个空间隙并在该间隙的深度处创建一个新节点,然后找到下一个重叠节点(如果存在),然后连接两个 ( 4.).
if (firstOverlap == null) { /* ... */ }
else if (firstOverlap.currentDepth > 0) { /* ... */ }
else {
    // insert an overlapping node at a deeper depth into the first overlap-free gap
    val overlap = last
        .traverse { it.next.iterator() }
        .fold(null as Node?) { lastOverlap, next ->
            val adjacent = lastOverlap == null || lastOverlap.currentDepth + 1 == next.currentDepth
            if (adjacent && currentOverlappingWith(next)) next else lastOverlap
        }!!

    // create the new node at the depth of the insertion
    val node = Node(elementToInsert, currentDepth = overlap.currentDepth + 1)
    node.totalDepth = overlap.totalDepth.coerceAtLeast(node.totalDepth)
    node.visualDebug = "3"

    // check if there is an overlap deeper down the hierarchy
    val nextOverlap = overlap
        .traverse { it.next.iterator() }
        .firstOrNull { it !== overlap && currentOverlappingWith(it) }
    if (nextOverlap != null) {
        node.visualDebug = "4"

        // remove the direct connection between overlap and nextOverlap if it exists
        overlap.next.remove(nextOverlap)
        nextOverlap.prev.remove(overlap)

        // add the direct connection between the new node and the next overlap
        nextOverlap.prev.add(node)
        node.next.add(nextOverlap)
        node.nextDepth = nextOverlap
        node.totalDepth = nextOverlap.totalDepth
    }

    // add the connection between overlap and the new node
    node.prev.add(overlap)
    overlap.next.add(node)
    
    // ... (there is a bit more code, see in the full code below)
}
  • 将所有元素添加到图形后,代码将遍历所有图形并为每个图形节点调用 factory 函数,传递元素、起始权重、结束权重和重叠计数到调用。 factory 函数负责创建应返回的结果而不是节点。
val visited = mutableSetOf<Node>()
return nonOverlappingNodes.flatMap { node: Node ->
    node.traverse { it.next.iterator() }
        .filter(visited::add) // only process each node once
        .map {
            val startWeight = it.currentDepth / it.totalDepth.toFloat()
            val endWeight = (it.nextDepth?.currentDepth ?: it.totalDepth) / it.totalDepth.toFloat()
            factory(it.element, startWeight, endWeight, it.totalDepth, it.visualDebug)
        }
}

我还定义了一个 Booking 类和一个 EventData 类来创建一个小型使用演示。

data class Booking(
    val startTimestamp: LocalDateTime,
    val endTimestamp: LocalDateTime,
) {
    val duration = Duration.between(startTimestamp, endTimestamp)

    companion object {
        fun areOverlapping(a: Booking, b: Booking): Boolean {
            return a.startTimestamp < b.endTimestamp && b.startTimestamp < a.endTimestamp
        }
    }
}

data class EventData(
    val booking: Booking, val startWeight: Float, val endWeight: Float, val overlapCount: Int,
    val visualDebug: String,
) : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?) = this@EventData
}

这就是它在调用站点的使用方式:

val bookings: List<Booking> // = ...

val events = remember(bookings) {
    bookings.splitHierarchically(
        comparator = compareBy(Booking::startTimestamp).thenByDescending(Booking::duration),
        areOverlapping = Booking::areOverlapping,
        factory = { booking, startWeight, endWeight, overlapCount, visualDebug ->
            EventData(booking, startWeight, endWeight, overlapCount, visualDebug)
        },
    )
}

这就是创建布局时使用事件数据的方式

    // ...
    val eventWidth = ((eventData.endWeight - eventData.startWeight) * availableWidth).roundToInt()
    val placeable = measurable.measure(Constraints.fixed(eventWidth, eventHeight))

    // ...
    val eventX = (eventData.startWeight * availableWidth).roundToInt()
    placeable.place(eventX, eventY)

完整代码

将下面的整个代码复制到一个空的 Kotlin 文件中,在 IDE 中打开它并检查 Compose Preview 窗口。 This UI should show up .

您还可以从主 Activity 调用 OverlappingScheduleUI 可组合项来运行示例代码。

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import java.time.Duration
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt

// set to true to see some debug info directly on each event
const val showVisualDebug = true

@Preview(showBackground = true)
@Composable
fun OverlappingScheduleUI() {
    val bookings: List<Booking> = LocalDateTime.parse("2023-04-28T08:00:00").let { base ->
        listOf(
            Booking(base.plusMinutes(  0), base.plusMinutes(  0 + 60)),
            Booking(base.plusMinutes( 10), base.plusMinutes( 10 + 60)),
            Booking(base.plusMinutes( 20), base.plusMinutes( 20 + 60)),
            Booking(base.plusMinutes( 30), base.plusMinutes( 30 + 90)),
            Booking(base.plusMinutes( 50), base.plusMinutes( 90 + 120)),

            Booking(base.plusMinutes( 90), base.plusMinutes( 90 + 60)),
            Booking(base.plusMinutes(100), base.plusMinutes(100 + 60)),

            Booking(base.plusMinutes(160), base.plusMinutes(160 + 90)),
            Booking(base.plusMinutes(170), base.plusMinutes(170 + 45)),
            Booking(base.plusMinutes(230), base.plusMinutes(230 + 60)),
            Booking(base.plusMinutes(230), base.plusMinutes(230 + 60)),
            Booking(base.plusMinutes(230), base.plusMinutes(230 + 60)),

            Booking(base.plusMinutes(300), base.plusMinutes(300 + 60)),
            Booking(base.plusMinutes(320), base.plusMinutes(320 + 60)),
        )
    }

    val events = remember(bookings) {
        bookings.splitHierarchically(
            comparator = compareBy(Booking::startTimestamp).thenByDescending(Booking::duration),
            areOverlapping = Booking::areOverlapping,
            factory = { booking, startWeight, endWeight, overlapCount, visualDebug ->
                EventData(booking, startWeight, endWeight, overlapCount, visualDebug)
            },
        )
    }

    Layout(content = {
        val dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
        events.forEach { event ->
            Box(modifier = Modifier
                .then(event)
                .background(color = Color(0f, 0.5f, 1f - event.startWeight * 0.6f))) {
                Column {
                    if (showVisualDebug) Text(event.visualDebug, color = Color.White)
                    Text(event.booking.startTimestamp.format(dateTimeFormatter), color = Color.White)
                }
            }
        }
    }) { measureables, constraints ->
        val hourHeight = 64.dp
        val availableWidth = constraints.maxWidth

        val placeablesWithEvents = measureables.map { measurable ->
            val eventData = measurable.parentData as EventData
            val booking = eventData.booking
            val eventDurationMinutes = ChronoUnit.MINUTES.between(
                booking.startTimestamp,
                booking.endTimestamp
            )
            val eventHeight = ((eventDurationMinutes / 60f) * hourHeight.toPx()).roundToInt()
            val eventWidth = ((eventData.endWeight - eventData.startWeight) * availableWidth).roundToInt()
            val placeable = measurable.measure(Constraints.fixed(eventWidth, eventHeight))

            Pair(placeable, eventData)
        }

        layout(availableWidth, hourHeight.roundToPx() * 24) {
            placeablesWithEvents.forEach { (placeable, eventData) ->
                val booking = eventData.booking
                val eventOffsetMinutes = ChronoUnit.MINUTES.between(
                    LocalTime.MIN, booking.startTimestamp
                )
                val eventY = ((eventOffsetMinutes / 60f) * hourHeight.toPx()).roundToInt()
                val eventX = (eventData.startWeight * availableWidth).roundToInt()

                placeable.place(eventX, eventY, eventData.startWeight)
            }
        }
    }
}

data class Booking(
    val startTimestamp: LocalDateTime,
    val endTimestamp: LocalDateTime,
) {
    val duration = Duration.between(startTimestamp, endTimestamp)

    companion object {
        fun areOverlapping(a: Booking, b: Booking): Boolean {
            return a.startTimestamp < b.endTimestamp && b.startTimestamp < a.endTimestamp
        }
    }
}

data class EventData(
    val booking: Booking, val startWeight: Float, val endWeight: Float, val overlapCount: Int,
    val visualDebug: String,
) : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?) = this@EventData
}

/**
 * Traverses the graph-like data structure starting from the receiver.
 *
 * @param depthFirst if true, the traversal is depth-first, otherwise breadth-first
 * @param nextIterator a function that returns an iterator over the next nodes to visit
 * @return a sequence of nodes in the order they are visited
 */
fun <T> T.traverse(depthFirst: Boolean = false, nextIterator: (T) -> Iterator<T>): Sequence<T> {
    return sequence {
        val current = this@traverse
        if (!depthFirst) yield(current)

        val next = nextIterator(current)
        while (next.hasNext()) {
            yieldAll(next.next().traverse(depthFirst, nextIterator))
        }

        if (depthFirst) yield(current)
    }
}

/**
 * Splits the collection into non-overlapping groups.
 * 
 * @param comparator a comparator that orders the elements in the collection supplied through the receiver
 * @param areOverlapping a function that returns `true` if the two elements are overlapping
 * @param factory a function that creates the result from the element, start weight, end weight and overlap count
 * @return a list of results that the [factory] creates over the collection of the input elements
 */
fun <T, R> Collection<T>.splitHierarchically(
    comparator: Comparator<T>,
    areOverlapping: (a: T, b: T) -> Boolean,
    factory: (element: T, startWeight: Float, endWeight: Float, overlapCount: Int, visualDebug: String) -> R,
): List<R> {
    val elements = this

    if (elements.isEmpty()) return emptyList()

    class Node(
        val element: T,
        val currentDepth: Int,
        var totalDepth: Int = currentDepth + 1,
        val prev: MutableList<Node> = mutableListOf(),
        val next: MutableList<Node> = mutableListOf(),
    ) {
        var nextDepth: Node? = null
        var visualDebug = ""
    }

    // Sorting the items with their comparator
    // ensures a deterministic insertion order chosen by the caller
    val sortedElements = elements.sortedWith(comparator)

    val nonOverlappingNodes = mutableListOf<Node>()
    
    // Iterate through the sorted items and add them to one of the graphs.
    // If two items are overlapping they are connected in the same graph
    sortedElements.forEach { elementToInsert ->
        val currentOverlappingWith = { e: Node -> areOverlapping(e.element, elementToInsert) }
        
        val last = nonOverlappingNodes.lastOrNull()

        val firstOverlap = last
            ?.traverse { it.next.iterator() }
            ?.firstOrNull(currentOverlappingWith)

        if (firstOverlap == null) {
            // inserting a new non-overlapping node
            val node = Node(elementToInsert, currentDepth = 0)
            node.visualDebug = "1"
            nonOverlappingNodes.add(node)
        } else if (firstOverlap.currentDepth > 0) {
            val node = Node(elementToInsert, currentDepth = 0, totalDepth = firstOverlap.totalDepth)
            node.visualDebug = "2"

            firstOverlap.prev.add(node)
            node.next.add(firstOverlap)
            node.nextDepth = firstOverlap

            // inserting a new top node at depth 0 that has an overlap deeper down the hierarchy
            nonOverlappingNodes.add(node)
        }
        else {
            // insert an overlapping node at a deeper depth into the first overlap-free gap
            val overlap = last
                .traverse { it.next.iterator() }
                .fold(null as Node?) { lastOverlap, next ->
                    val adjacent = lastOverlap == null || lastOverlap.currentDepth + 1 == next.currentDepth
                    if (adjacent && currentOverlappingWith(next)) next else lastOverlap
                }!!

            // create the new node at the depth of the insertion
            val node = Node(elementToInsert, currentDepth = overlap.currentDepth + 1)
            node.totalDepth = overlap.totalDepth.coerceAtLeast(node.totalDepth)
            node.visualDebug = "3"

            val nextOverlap = overlap
                .traverse { it.next.iterator() }
                .firstOrNull { it !== overlap && currentOverlappingWith(it) }
            if (nextOverlap != null) {
                node.visualDebug = "4"

                // remove the direct connection between overlap and nextOverlap if it exists
                overlap.next.remove(nextOverlap)
                nextOverlap.prev.remove(overlap)

                // add the direct connection between the new node and the next overlap
                nextOverlap.prev.add(node)
                node.next.add(nextOverlap)
                node.nextDepth = nextOverlap
                node.totalDepth = nextOverlap.totalDepth
            }

            // add the connection between overlap and the new node
            node.prev.add(overlap)
            overlap.next.add(node)

            // updating the nextDepth of the overlap if the new node is closer
            if (node.currentDepth < (overlap.nextDepth?.currentDepth ?: Int.MAX_VALUE)) {
                overlap.nextDepth = node
            }

            // propagate the total depth inside the current graph only
            val totalDepth = node.totalDepth           

            val visited = mutableSetOf(node)
            node.traverse {
                iterator {
                    yieldAll(it.prev.filter(visited::add))
                    yieldAll(it.next.filter(visited::add))
                }
            }.forEach {
                it.totalDepth = totalDepth
            }
        }
    }

    val visited = mutableSetOf<Node>()
    return nonOverlappingNodes.flatMap { node: Node ->
        node.traverse { it.next.iterator() }
            .filter(visited::add) // only process each node once
            .map {
                val startWeight = it.currentDepth / it.totalDepth.toFloat()
                val endWeight = (it.nextDepth?.currentDepth ?: it.totalDepth) / it.totalDepth.toFloat()
                factory(it.element, startWeight, endWeight, it.totalDepth, it.visualDebug)
            }
    }
}

关于android - 如何比较多个时间范围并在 Android Compose 中并排显示它们,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/76123047/

相关文章:

kotlin - Kotlin 中的并发 Future 语法

android - Jetpack compose - 在文本后面绘制背景

android - 如何在jetpack compose中释放位图

java - Android将大输入流复制到文件非常慢

android - native SIP INFO 消息 (RFC 6086)

android - 在 Canvas 上绘制的 View 上设置 onClickListener

android - 如何将 `rememberNavController` 从喷气背包组合成使用刀柄的 Activity ?

android - NSD 和 WifiP2pManager 有什么不同?

java - Spring Boot - Jpa 独特且可分页

android - KotterKnife - 某些类不能使用 bindView(R.id.example_id)