[go: nahoru, domu]

blob: d80b6f0eefb0cc4a23a81078687b34efd13c8ff4 [file] [log] [blame]
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.ui.material
import androidx.compose.Composable
import androidx.compose.state
import androidx.ui.core.Alignment
import androidx.ui.core.Constraints
import androidx.ui.core.CurrentTextStyleProvider
import androidx.ui.core.Layout
import androidx.ui.core.ParentData
import androidx.ui.core.Text
import androidx.ui.foundation.Border
import androidx.ui.foundation.Box
import androidx.ui.foundation.Clickable
import androidx.ui.foundation.ColoredRect
import androidx.ui.foundation.ContentGravity
import androidx.ui.foundation.Image
import androidx.ui.foundation.drawBorders
import androidx.ui.foundation.selection.ToggleableState
import androidx.ui.graphics.Color
import androidx.ui.graphics.ImageAsset
import androidx.ui.layout.Arrangement
import androidx.ui.layout.Column
import androidx.ui.layout.EdgeInsets
import androidx.ui.layout.LayoutGravity
import androidx.ui.layout.LayoutHeight
import androidx.ui.layout.LayoutSize
import androidx.ui.layout.LayoutWidth
import androidx.ui.layout.Row
import androidx.ui.layout.Spacer
import androidx.ui.layout.Table
import androidx.ui.layout.TableColumnWidth
import androidx.ui.material.ripple.Ripple
import androidx.ui.text.TextStyle
import androidx.ui.text.font.FontWeight
import androidx.ui.unit.Dp
import androidx.ui.unit.IntPx
import androidx.ui.unit.dp
/**
* Pagination configuration for a [DataTable].
*/
data class DataTablePagination(
/**
* The index of the current page (starting from zero).
*/
val page: Int,
/**
* The number of rows to show on each page.
*/
val rowsPerPage: Int,
/**
* The options to offer for the number of rows per page.
*
* The current value of [rowsPerPage] must be in this list.
*/
val availableRowsPerPage: List<Int>,
/**
* Invoked when the user switches to another page.
*/
val onPageChange: (Int) -> Unit,
/**
* Invoked when the user selects a different number of rows per page.
*/
val onRowsPerPageChange: (Int) -> Unit
)
/**
* Creates a pagination configuration for [DataTable] with the given initial values.
*
* Example usage:
*
* @sample androidx.ui.material.samples.DataTableWithPagination
*/
@Composable
fun DefaultDataTablePagination(
initialPage: Int = 0,
initialRowsPerPage: Int,
availableRowsPerPage: List<Int>
): DataTablePagination {
val page = state { initialPage }
val rowsPerPage = state { initialRowsPerPage }
return DataTablePagination(
page = page.value,
rowsPerPage = rowsPerPage.value,
availableRowsPerPage = availableRowsPerPage,
onPageChange = { page.value = it },
onRowsPerPageChange = { rowsPerPage.value = it }
)
}
/**
* Sorting configuration for a [DataTable].
*/
data class DataTableSorting(
/**
* The index of the current column, if any, by which the data is sorted.
*
* When this is null, it implies that the table's sort order does not correspond to any of the
* columns. Setting this to a non-null value will display a sort indicator next to that column.
*/
val column: Int?,
/**
* Whether the column specified by [column], if non-null, is sorted in ascending order.
*/
val ascending: Boolean,
/**
* The columns by which the data can be sorted.
*
* The current value of [column], if non-null, must be in this set.
*/
val sortableColumns: Set<Int>,
/**
* Called when the user asks to sort the table.
*/
val onSortChange: (column: Int, ascending: Boolean) -> Unit
)
/**
* Creates a sorting configuration for [DataTable] with the given initial values.
*
* Example usage:
*
* @sample androidx.ui.material.samples.DataTableWithSorting
*/
@Composable
fun DefaultDataTableSorting(
initialColumn: Int? = null,
initialAscending: Boolean = true,
sortableColumns: Set<Int>,
onSortRequest: (column: Int, ascending: Boolean) -> Unit
): DataTableSorting {
val column = state { initialColumn }
val ascending = state { initialAscending }
return DataTableSorting(
column = column.value,
ascending = ascending.value,
sortableColumns = sortableColumns,
onSortChange = { newColumn, newAscending ->
column.value = newColumn
ascending.value = newAscending
onSortRequest(newColumn, newAscending)
}
)
}
/**
* Collects information about the children of a [DataTable] when
* its body is executed with a [DataTableChildren] as argument.
*/
class DataTableChildren internal constructor() {
internal var header: HeaderRowInfo? = null
internal val rows = mutableListOf<DataRowInfo>()
/**
* Creates a data row in a [DataTable] with the given content.
*
* If [onSelectedChange] is non-null for any row in the table, then a checkbox is shown at the
* start of each row. The checkbox will be checked if and only if the row is selected (true).
*
* @param selected Whether this row is selected.
* @param onSelectedChange Called when a user selects or unselects this row.
*/
fun dataRow(
selected: Boolean = false,
onSelectedChange: ((Boolean) -> Unit)? = null,
children: @Composable() (index: Int) -> Unit
) {
rows += DataRowInfo(children, selected, onSelectedChange)
}
/**
* Creates a data row in a [DataTable] with a [text] and an optional [icon].
*
* If [onSelectedChange] is non-null for any row in the table, then a checkbox is shown at the
* start of each row. The checkbox will be checked if and only if the row is selected (true).
*
* @param text Text to display in each cell.
* @param icon Optional image to draw to the left of the text in each cell.
* @param selected Whether this row is selected.
* @param onSelectedChange Called when a user selects or unselects this row.
*/
fun dataRow(
text: (index: Int) -> String,
icon: (index: Int) -> ImageAsset? = { null },
selected: Boolean = false,
onSelectedChange: ((Boolean) -> Unit)? = null
) {
val children: @Composable() (Int) -> Unit = { j ->
val image = icon(j)
if (image == null) {
Text(text = text(j))
} else {
Row {
Image(image = image)
Spacer(LayoutWidth(2.dp))
Text(text = text(j))
}
}
}
rows += DataRowInfo(children, selected, onSelectedChange)
}
/**
* Creates a header row in a [DataTable] with the given content.
*
* Note that the [onSelectAll] callback may be null, in which case the default behaviour will
* be used, i.e. select or unselect all selectable rows using their onSelectedChange callbacks.
*
* @param onSelectAll Called when a user selects or unselects all rows using the 'all' checkbox.
*/
fun headerRow(
onSelectAll: ((Boolean) -> Unit)? = null,
children: @Composable() (index: Int) -> Unit
) {
header = HeaderRowInfo(children, onSelectAll)
}
/**
* Creates a header row in a [DataTable] with a [text] and an optional [icon].
*
* Note that the [onSelectAll] callback may be null, in which case the default behaviour will
* be used, i.e. select or unselect all selectable rows using their onSelectedChange callbacks.
*
* @param text Text to display in each column header
* @param icon Optional image to draw to the left of the text in each column header.
* @param onSelectAll Called when a user selects or unselects all rows using the 'all' checkbox.
*/
fun headerRow(
text: (index: Int) -> String,
icon: (index: Int) -> ImageAsset? = { null },
onSelectAll: ((Boolean) -> Unit)? = null
) {
val children: @Composable() (Int) -> Unit = { j ->
val image = icon(j)
if (image == null) {
Text(text = text(j))
} else {
Row {
Image(image = image)
Spacer(LayoutWidth(2.dp))
Text(text = text(j))
}
}
}
header = HeaderRowInfo(children, onSelectAll)
}
}
/**
* Configuration for the data row of a [DataTable].
*/
internal data class DataRowInfo(
val children: @Composable() (index: Int) -> Unit,
val selected: Boolean,
val onSelectedChange: ((Boolean) -> Unit)?
)
/**
* Configuration for the header row of a [DataTable].
*/
internal data class HeaderRowInfo(
val children: @Composable() (index: Int) -> Unit,
val onSelectAll: ((Boolean) -> Unit)?
)
/**
* Data tables display information in a grid-like format of rows and columns. They organize
* information in a way that’s easy to scan, so that users can look for patterns and insights.
*
* Example usage:
*
* @sample androidx.ui.material.samples.SimpleDataTable
*
* To make a data table paginated, you must provide a [pagination] configuration:
*
* @sample androidx.ui.material.samples.DataTableWithPagination
*
* To enable sorting when clicking on the column headers, provide a [sorting] configuration:
*
* @sample androidx.ui.material.samples.DataTableWithSorting
*
* @param columns The number of columns in the table.
* @param numeric Whether the given column represents numeric data.
* @param dataRowHeight The height of each row (excluding the header row).
* @param headerRowHeight The height of the header row.
* @param cellSpacing The padding to apply around each cell.
* @param border [Border] class that specifies border appearance, such as size or color.
* @param selectedColor The color used to indicate selected rows.
* @param pagination Contains the pagination configuration. To disable pagination, set this to null.
* @param sorting Contains the sorting configuration. To disable sorting, set this to null.
*/
@Composable
fun DataTable(
columns: Int,
numeric: (Int) -> Boolean = { false },
dataRowHeight: Dp = DataRowHeight,
headerRowHeight: Dp = HeaderRowHeight,
cellSpacing: EdgeInsets = CellSpacing,
border: Border = Border(color = BorderColor, size = BorderWidth),
selectedColor: Color = MaterialTheme.colors().primary.copy(alpha = 0.08f),
pagination: DataTablePagination? = null,
sorting: DataTableSorting? = null,
block: DataTableChildren.() -> Unit
) {
val scope = DataTableChildren()
scope.block()
val rows = scope.rows
val header = scope.header
val selectableRows = rows.filter { it.onSelectedChange != null }
val showCheckboxes = selectableRows.isNotEmpty()
val visibleRows = if (pagination == null) {
rows
} else {
rows.drop(pagination.rowsPerPage * pagination.page).take(pagination.rowsPerPage)
}
val table = @Composable {
Table(
columns = columns + if (showCheckboxes) 1 else 0,
alignment = { j ->
if (numeric(j - if (showCheckboxes) 1 else 0)) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
}
},
columnWidth = { j ->
if (showCheckboxes && j == 0) {
TableColumnWidth.Wrap
} else {
TableColumnWidth.Wrap.flexible(flex = 1f)
}
}
) {
// Table borders
drawBorders(defaultBorder = border) {
allHorizontal()
}
// Header row
if (header != null) {
tableRow {
if (showCheckboxes) {
Box(
LayoutHeight(headerRowHeight),
paddingStart = cellSpacing.left,
paddingTop = cellSpacing.top,
paddingEnd = cellSpacing.right,
paddingBottom = cellSpacing.bottom,
gravity = ContentGravity.Center
) {
val parentState = when (selectableRows.count { it.selected }) {
selectableRows.size -> ToggleableState.On
0 -> ToggleableState.Off
else -> ToggleableState.Indeterminate
}
TriStateCheckbox(value = parentState, onClick = {
val newValue = parentState != ToggleableState.On
if (header.onSelectAll != null) {
header.onSelectAll.invoke(newValue)
} else {
rows.forEach { it.onSelectedChange?.invoke(newValue) }
}
})
}
}
for (j in 0 until columns) {
Box(
LayoutHeight(headerRowHeight),
paddingStart = cellSpacing.left,
paddingTop = cellSpacing.top,
paddingEnd = cellSpacing.right,
paddingBottom = cellSpacing.bottom,
gravity = ContentGravity.Center
) {
var fontWeight = FontWeight.W500
var onSort = {}
var enabled = false
var headerDecoration: @Composable() (() -> Unit)? = null
if (sorting != null && sorting.sortableColumns.contains(j)) {
if (sorting.column == j) {
fontWeight = FontWeight.Bold
onSort = {
sorting.onSortChange(j, !sorting.ascending)
}
enabled = true
headerDecoration = {
// TODO(calintat): Replace with animated arrow icons.
Text(text = if (sorting.ascending) "↑" else "↓")
Spacer(LayoutWidth(2.dp))
}
} else {
onSort = {
sorting.onSortChange(j, true)
}
}
}
CurrentTextStyleProvider(TextStyle(fontWeight = fontWeight)) {
Ripple(bounded = true) {
Clickable(onClick = onSort, enabled = enabled) {
Row {
headerDecoration?.invoke()
header.children(index = j)
}
}
}
}
}
}
}
}
// Data rows
visibleRows.forEach { row ->
tableRow {
if (showCheckboxes) {
Box(
LayoutHeight(dataRowHeight),
paddingStart = cellSpacing.left,
paddingTop = cellSpacing.top,
paddingEnd = cellSpacing.right,
paddingBottom = cellSpacing.bottom,
gravity = ContentGravity.Center
) {
Checkbox(row.selected, row.onSelectedChange)
}
}
for (j in 0 until columns) {
Box(
LayoutHeight(dataRowHeight),
paddingStart = cellSpacing.left,
paddingTop = cellSpacing.top,
paddingEnd = cellSpacing.right,
paddingBottom = cellSpacing.bottom,
gravity = ContentGravity.Center
) {
row.children(index = j)
}
}
}
}
// Data rows ripples
tableDecoration(overlay = false) {
val children = @Composable {
visibleRows.forEachIndexed { index, row ->
if (row.onSelectedChange == null) return@forEachIndexed
ParentData(data = index) {
Ripple(bounded = true) {
Clickable(
onClick = { row.onSelectedChange.invoke(!row.selected) }
) {
ColoredRect(
color = if (row.selected) {
selectedColor
} else {
Color.Transparent
}
)
}
}
}
}
}
Layout(children) { measurables, constraints, _ ->
layout(constraints.maxWidth, constraints.maxHeight) {
measurables.forEach { measurable ->
val i = measurable.parentData as Int
val placeable = measurable.measure(
Constraints.fixed(
width = constraints.maxWidth,
height = verticalOffsets[i + 2] - verticalOffsets[i + 1]
)
)
placeable.placeAbsolute(
x = IntPx.Zero,
y = verticalOffsets[i + 1]
)
}
}
}
}
}
}
if (pagination == null) {
table()
} else {
Column {
table()
Box(
LayoutHeight(dataRowHeight),
paddingStart = cellSpacing.left,
paddingTop = cellSpacing.top,
paddingEnd = cellSpacing.right,
paddingBottom = cellSpacing.bottom,
gravity = ContentGravity.Center
) {
Row(LayoutSize.Fill, arrangement = Arrangement.End) {
val pages = (rows.size - 1) / pagination.rowsPerPage + 1
val startRow = pagination.rowsPerPage * pagination.page
val endRow = (startRow + pagination.rowsPerPage).coerceAtMost(rows.size)
val modifier = LayoutGravity.Center
// TODO(calintat): Replace this with a dropdown menu whose items are taken
// from availableRowsPerPage (filtered to those that are in the range
// 0 until rows.size). When an item is selected, it should invoke
// onRowsPerPageChange with the appropriate value.
Text(text = "Rows per page: ${pagination.rowsPerPage}", modifier = modifier)
Spacer(LayoutWidth(32.dp))
Text(text = "${startRow + 1}-$endRow of ${rows.size}", modifier = modifier)
Spacer(LayoutWidth(32.dp))
// TODO(calintat): Replace this with an image button with chevron_left icon.
Box(modifier = modifier) {
Ripple(bounded = false) {
Clickable(onClick = {
val newPage = pagination.page - 1
if (newPage >= 0)
pagination.onPageChange.invoke(newPage)
}) {
Text(text = "Prev")
}
}
}
Spacer(LayoutWidth(24.dp))
// TODO(calintat): Replace this with an image button with chevron_right icon.
Box(modifier = modifier) {
Ripple(bounded = false) {
Clickable(onClick = {
val newPage = pagination.page + 1
if (newPage < pages)
pagination.onPageChange.invoke(newPage)
}) {
Text(text = "Next")
}
}
}
}
}
}
}
}
private val DataRowHeight = 52.dp
private val HeaderRowHeight = 56.dp
private val CellSpacing = EdgeInsets(left = 16.dp, right = 16.dp)
private val BorderColor = Color(0xFFC6C6C6)
private val BorderWidth = 1.dp