File Drop
Somewhere to give the app a file: a zone with a dashed edge that opens the platform's picker when it is pressed, and takes the files dropped on it from the platform's files, another app or another window.
import com.pantrixui.components.elements.filedrop.PantrixFileDrop
- Pressing works everywhere. Pressing the zone (or Enter or Space while it has the focus) opens the platform's picker: the documents on Android, Files on iOS, the system's window on the desktop, the browser's on the web. Where only pictures are taken, Android and iOS open the photo picker. None of them asks for a permission.
- Dropping is a pointer's work. On the desktop and the web files are dragged over the zone and dropped; on Android and the iPad from an app beside it too. While something is dragged over it the zone turns the primary role's colour and says "Drop to add". Its words follow suit: on the desktop and the web they put dropping first, on a phone choosing.
- Screening.
acceptsets the kinds taken,maxSizethe largest size,multiplemore than one file. Files that do not fit go toonRejectedwith their reasons (kind, size, number), the rest toonFiles; what to say is the app's. - The zone keeps no state. The app keeps the files taken and lists them under the zone as
PantrixFileItemrows: the name (its middle cut when long), the size in the language's way, the remove button, an optional progress and an error. - Reading. A
PantrixFilecarries its name, size and type, and reads its bytes whenreadBytes()asks. The platform's own file is there too:urion Android,urlon iOS,fileon the desktop,domFileon the web. - Platform. The zone is 140dp tall on a phone and 120dp on the desktop and the web; the rows are 56dp on a phone and 44dp under a pointer.
Examples
Attachments: kinds, size and rows
A form's attachments: PDF or pictures, several, each at most 10 MB. Every file taken becomes a row and gets a bar that fills as if it were being sent; the remove button deletes the row. The ones not taken are written in the supporting text with their reasons.
val attachments = remember { mutableStateListOf<PantrixFile>() }
val sent = remember { mutableStateMapOf<PantrixFile, Float>() }
var refused by remember { mutableStateOf(emptyList<RejectedFile>()) }
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
PantrixFileDrop(
onFiles = { files ->
attachments += files
refused = emptyList()
},
accept = listOf(PantrixFileType.Pdf, PantrixFileType.Images),
multiple = true,
maxSize = TEN_MEGABYTES,
onRejected = { refused = it },
supportingText = if (refused.isEmpty()) "PDF or a picture, each at most 10 MB" else reasonOf(refused),
error = refused.isNotEmpty(),
tagKey = "attachments"
)
attachments.forEach { file ->
key(file) {
SendingLater(file, sent)
PantrixFileItem(
name = file.name,
size = file.size,
onRemove = {
attachments -= file
sent -= file
},
progress = sent[file]?.takeIf { it < 1f },
tagKey = file.name
)
}
}
}
@Composable
private fun SendingLater(
file: PantrixFile,
sent: MutableMap<PantrixFile, Float>
) {
LaunchedEffect(file) {
if (file in sent) return@LaunchedEffect
repeat(STEPS + 1) { step ->
sent[file] = step / STEPS.toFloat()
delay(STEP_MILLIS)
}
}
}
private fun reasonOf(refused: List<RejectedFile>): String = refused.groupBy { it.reason }.entries.joinToString(" · ") {
val names = it.value.joinToString { rejected -> rejected.file.name }
when (it.key) {
FileRejection.Type -> "This kind is not taken: $names"
FileRejection.Size -> "Too large: $names"
FileRejection.Count -> "Only one file is taken: $names"
}
}
private const val TEN_MEGABYTES = 10_000_000L
private const val STEPS = 10
private const val STEP_MILLIS = 150L
One picture and the reason it was refused
One picture, at most 2 MB: a profile picture. On a phone the photo picker opens. The chosen picture is read and shown; a larger one, or a file that is no picture, is refused, and why is written under the zone as an error.
var avatar by remember { mutableStateOf<PantrixFile?>(null) }
var refused by remember { mutableStateOf<RejectedFile?>(null) }
var picture by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(avatar) {
// The bytes are read only when asked for: here, once the zone's work is done.
picture = avatar?.let { file -> runCatching { file.readBytes().decodeToImageBitmap() }.getOrNull() }
}
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
PantrixFileDrop(
onFiles = { files ->
avatar = files.single()
refused = null
},
accept = listOf(PantrixFileType.Images),
maxSize = TWO_MEGABYTES,
onRejected = { refused = it.first() },
label = "Choose a profile picture",
supportingText = refused?.let { reasonOf(listOf(it)) } ?: "PNG, JPEG or WebP, at most 2 MB",
error = refused != null,
tagKey = "avatar"
)
picture?.let {
PantrixImage(bitmap = it, contentDescription = "The chosen picture", modifier = Modifier.size(PICTURE_SIZE))
}
avatar?.let { PantrixFileItem(name = it.name, size = it.size, onRemove = { avatar = null }, tagKey = "avatar") }
}
private fun reasonOf(refused: List<RejectedFile>): String = refused.groupBy { it.reason }.entries.joinToString(" · ") {
val names = it.value.joinToString { rejected -> rejected.file.name }
when (it.key) {
FileRejection.Type -> "This kind is not taken: $names"
FileRejection.Size -> "Too large: $names"
FileRejection.Count -> "Only one file is taken: $names"
}
}
private val PICTURE_SIZE = 96.dp
private const val TWO_MEGABYTES = 2_000_000L
The zone's own words, colours, and disabled
The zone's own words and colours: the caller writes its first line, and its fill and edge are the app's own colour. While the switch is off the zone takes nothing: it is not pressed, what is dropped on it is not taken, and it is drawn faint.
var open by rememberSaveable { mutableStateOf(true) }
var taken by remember { mutableStateOf(0) }
val own = ownColors()
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)) {
PantrixSwitchButton(checked = open, onCheckedChange = { open = it }, label = "The zone takes files", tagKey = "accepting")
PantrixFileDrop(
onFiles = { taken += it.size },
multiple = true,
label = "Drop your reports here",
enabled = open,
colors = FileDropColors(container = own.container, border = own.color, action = own.color),
tagKey = "reports"
)
PantrixText(
text = "Files taken: $taken",
color = PantrixTheme.colors.textSecondary,
tagKey = "taken"
)
}
A low area and dense rows in their own style
In their own style: FileDropStyle gives the drop area's height, its dashed line and its corners, FileItemStyle a file row's height and its progress line. Every field not given is the platform's preset.
val files = remember { mutableStateListOf<PantrixFile>() }
Column(verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.xs)) {
PantrixFileDrop(
onFiles = { files += it },
multiple = true,
style = FileDropStyle(minHeight = 72.dp, shape = ShapeRole.Large, dashLength = 4.dp, dashGap = 4.dp),
tagKey = "low"
)
files.forEach { file ->
key(file) {
PantrixFileItem(
name = file.name,
size = file.size,
onRemove = { files -= file },
style = FileItemStyle(minHeight = 40.dp, verticalPadding = 4.dp, progressHeight = 2.dp),
tagKey = file.name
)
}
}
}
API
onFiles
Somewhere to give the app a file: a zone with a dashed edge that opens the platform's picker when it is pressed, and takes the files dropped on it from the platform's files, another app or another window.
| Parameter | Type | Default | What it is |
|---|---|---|---|
onFiles | (List<PantrixFile>) -> Unit | handed the files the zone took, at least one, in the order they came. | |
modifier | Modifier | Modifier | the zone's modifier, with its supporting text. The zone fills the width it is given. |
accept | List<PantrixFileType> | listOf(PantrixFileType.Any) | the kinds of file the zone takes. The picker shows only these, where the platform's can filter. |
multiple | Boolean | false | whether the zone takes more than one file at a time. |
maxSize | Long? | null | the most bytes a file the zone takes may have, or null for any size. |
onRejected | (List<RejectedFile>) -> Unit | {} | handed the files the zone did not take, each with why, at least one. |
label | String? | null | what the zone says first, in place of the platform's words. |
supportingText | String? | null | a line under the zone: what it takes and how large, or what was wrong. |
enabled | Boolean | true | whether the zone takes anything: a press or a drop. A zone that does not is drawn faint. |
error | Boolean | false | whether the zone shows that what it was given was not right: its edge and its supporting text in the danger colour. A screen reader hears the supporting text as the error. |
picker | FilePicker | rememberFilePicker() | what a press opens: the platform's own picker, or a test's. |
interactionSource | MutableInteractionSource? | null | the zone's interactions: its press, its hover and its focus. |
style | FileDropStyle? | null | this call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform. |
colors | FileDropColors? | null | this zone's own colours: the ones it sets, with the theme's for the rest. |
tagKey | String? | null | the zone's automation key: it is FileDrop_<tagKey>. |
FileDropStyle
PantrixFileDrop's geometry: the zone, its dashed edge and what stands in it.
| Parameter | Type | Default | What it is |
|---|---|---|---|
shape | ShapeSpec | ShapeSpec.Unspecified | The zone's corners: a step of the brand's shape scale, or a fixed shape. |
minHeight | Dp | Dp.Unspecified | The least height of the zone. A larger type makes it taller. |
padding | Dp | Dp.Unspecified | The room between the zone's edge and what stands in it. |
borderWidth | Dp | Dp.Unspecified | The width of the dashed edge. |
dashLength | Dp | Dp.Unspecified | The length of each of the edge's dashes. |
dashGap | Dp | Dp.Unspecified | The room between two dashes. |
iconSize | Dp | Dp.Unspecified | The width and height of the icon over the zone's words. |
gap | Dp | Dp.Unspecified | Between the icon and the words, and between the two lines of words. |
FileDropColors
What a PantrixFileDrop is painted with, in place of the theme's colours: a colour that is set is used, and one that is left out stays the theme's.
| Parameter | Type | Default | What it is |
|---|---|---|---|
container | Color | Color.Unspecified | The zone's fill. The theme's surface. |
content | Color | Color.Unspecified | The zone's first line and its icon. The theme's textPrimary, or the colour a role's fill given as container gives what stands on it. |
action | Color | Color.Unspecified | The second line, the press that opens the picker, as a link is. The primary role's color on the theme's own grounds, and content on another fill. |
border | Color | Color.Unspecified | The dashed edge. The theme's border, borderHover under a pointer, on its own grounds; a share of content on another fill, as a line on a fill is. |
overContainer | Color | Color.Unspecified | The zone's fill while something is dragged over it. The primary role's container. |
overContent | Color | Color.Unspecified | What is written in it then. The primary role's onContainer. |
overBorder | Color | Color.Unspecified | The edge then. The primary role's color. |
error | Color | Color.Unspecified | The edge and the supporting text of a zone that shows an error. The danger role's color. |
supporting | Color | Color.Unspecified | The supporting text under the zone. The theme's textSecondary. |
Accessibility
- Screen reader. The whole zone is one button. How many files were taken and how many were not is announced politely; with
errorthe supporting text is read as the error.