Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions resources/android/ListItemRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
Expand All @@ -12,6 +13,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
Expand Down Expand Up @@ -111,24 +113,39 @@ object ListItemRenderer {
trailingIconColor = if (trailingIconColor != 0) Color(trailingIconColor) else Color.Unspecified
)

// `trailing_align = center`: M3 classifies a row with both an
// overline and supporting text as three-line and pins the leading
// and trailing slots to the top (iOS centres them). Drawing the
// overline inside the headline slot keeps the row two-line, so
// both slots are vertically centred. The overline keeps the
// labelSmall / onSurfaceVariant styling M3 would have applied.
val centerSlots = p.getString("trailing_align", "") == "center"
val overlineText: @Composable () -> Unit = {
Text(
text = overline,
fontFamily = nuiDefaultFontFamily(),
style = if (centerSlots) MaterialTheme.typography.labelSmall else LocalTextStyle.current,
color = when {
overlineColor != 0 -> Color(overlineColor)
centerSlots -> MaterialTheme.colorScheme.onSurfaceVariant
else -> Color.Unspecified
}
)
}

ListItem(
headlineContent = {
Text(
text = headline,
fontFamily = nuiDefaultFontFamily(),
color = if (headlineColor != 0) Color(headlineColor) else Color.Unspecified
)
},
modifier = clickModifier,
overlineContent = if (overline.isNotEmpty()) {
{
Column {
if (centerSlots && overline.isNotEmpty()) overlineText()
Text(
text = overline,
text = headline,
fontFamily = nuiDefaultFontFamily(),
color = if (overlineColor != 0) Color(overlineColor) else Color.Unspecified
color = if (headlineColor != 0) Color(headlineColor) else Color.Unspecified
)
}
} else null,
},
modifier = clickModifier,
overlineContent = if (overline.isNotEmpty() && !centerSlots) overlineText else null,
supportingContent = if (supporting.isNotEmpty()) {
{
Text(
Expand Down Expand Up @@ -164,6 +181,7 @@ object ListItemRenderer {
checkedInitial = trailingCheckedInitial,
iconColor = trailingIconColor,
textColor = trailingTextColor,
textStyle = p.getString("trailing_text_style", ""),
onChangeCb = onTrailingChangeCb,
onPressCb = onTrailingPressCb,
nodeId = node.id,
Expand Down Expand Up @@ -369,6 +387,7 @@ object ListItemRenderer {
checkedInitial: Boolean,
iconColor: Int,
textColor: Int,
textStyle: String = "",
onChangeCb: Int,
onPressCb: Int,
nodeId: Int,
Expand Down Expand Up @@ -400,9 +419,13 @@ object ListItemRenderer {
)
}
"text" -> {
// M3 styles the trailing slot as labelSmall (11sp);
// `trailing_text_style = headline` sizes it like the
// headline instead (bodyLarge), as iOS does.
Text(
text = effectiveValue,
fontFamily = nuiDefaultFontFamily(),
style = if (textStyle == "headline") MaterialTheme.typography.bodyLarge else LocalTextStyle.current,
color = if (textColor != 0) Color(textColor) else Color.Unspecified
)
}
Expand Down
15 changes: 12 additions & 3 deletions resources/ios/NativeUIListItemRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,17 @@ struct NativeUIListItemRenderer: View {
let trailingValue = p.getString("trailing_value")
let trailingIcon = p.getString("trailing_icon")
let trailingTextColor = p.getColor("trailing_text_color", default: 0)
// Opt-in cross-platform trailing props. Unset keeps this platform's
// default (centred slots, 17pt trailing text); set, both platforms
// draw the same thing — `top` mirrors Material's three-line rows,
// `label` its small trailing label.
let trailingAlign = p.getString("trailing_align", default: "")
let trailingTextStyle = p.getString("trailing_text_style", default: "")
let trailingIconColor = p.getColor("trailing_icon_color", default: 0)
let trailingChecked = p.getBool("trailing_checked")
let onTrailingChangeCb = p.getCallbackId("on_trailing_change")

HStack(spacing: 16) {
HStack(alignment: trailingAlign == "top" ? .top : .center, spacing: 16) {
// Leading content
buildLeadingContent(
type: leadingType.isEmpty ? (leadingIcon.isEmpty ? "" : "icon") : leadingType,
Expand Down Expand Up @@ -106,6 +112,7 @@ struct NativeUIListItemRenderer: View {
value: trailingValue.isEmpty ? trailingIcon : trailingValue,
iconColor: trailingIconColor,
textColor: trailingTextColor,
textStyle: trailingTextStyle,
checked: trailingChecked,
changeCb: onTrailingChangeCb
)
Expand Down Expand Up @@ -240,15 +247,17 @@ struct NativeUIListItemRenderer: View {
}

@ViewBuilder
private func buildTrailingContent(type: String, value: String, iconColor: Int, textColor: Int, checked: Bool = false, changeCb: Int = 0) -> some View {
private func buildTrailingContent(type: String, value: String, iconColor: Int, textColor: Int, textStyle: String = "", checked: Bool = false, changeCb: Int = 0) -> some View {
switch type {
case "icon":
Image(systemName: getIconForName(value))
.frame(width: 24, height: 24)
.foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary)
case "text":
// `label` mirrors Material's small trailing label; default and
// `headline` keep the headline-sized text iOS has always drawn.
Text(value)
.nuiScaledFont(size: 17)
.nuiScaledFont(size: textStyle == "label" ? 13 : 17)
.foregroundColor(textColor != 0 ? Color(argb: textColor) : .secondary)
case "icon_button":
// Spoken name for the icon-only trailing button: explicit
Expand Down
41 changes: 41 additions & 0 deletions src/Elements/ListItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ public function applyAttributes(array $attrs): void
if (isset($attrs['trailingTextColor'])) {
$this->trailingTextColor($attrs['trailingTextColor']);
}
if (isset($attrs['trailingTextStyle'])) {
$this->trailingTextStyle($attrs['trailingTextStyle']);
}
if (isset($attrs['trailingAlign'])) {
$this->trailingAlign($attrs['trailingAlign']);
}

// Elevation
if (isset($attrs['tonalElevation'])) {
Expand Down Expand Up @@ -511,6 +517,41 @@ public function trailingTextColor(string $color): static
return $this;
}

/**
* Size of `trailingText`: `label` (a small caption — Material's default
* on Android) or `headline` (the same size as the headline — iOS's
* default). Unset keeps each platform's default; set, both platforms
* draw the same thing.
*/
public function trailingTextStyle(string $style): static
{
if (! in_array($style, ['label', 'headline'], true)) {
throw new \InvalidArgumentException("list-item trailingTextStyle must be 'label' or 'headline', got '{$style}'");
}

$this->listItemProps['trailing_text_style'] = $style;

return $this;
}

/**
* Vertical placement of the leading and trailing slots: `top` (Material's
* default for rows with an overline and supporting text) or `center`
* (iOS's default). Unset keeps each platform's default; set, both
* platforms draw the same thing. On Android `center` also gives the row
* Material's two-line spacing.
*/
public function trailingAlign(string $align): static
{
if (! in_array($align, ['top', 'center'], true)) {
throw new \InvalidArgumentException("list-item trailingAlign must be 'top' or 'center', got '{$align}'");
}

$this->listItemProps['trailing_align'] = $align;

return $this;
}

public function tonalElevation(float $dp): static
{
$this->listItemProps['tonal_elevation'] = $dp;
Expand Down
60 changes: 60 additions & 0 deletions tests/ListItemTrailingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

use Native\Mobile\Edge\CallbackRegistry;
use Native\Mobile\Edge\ElementRegistry;
use Native\Mobile\Edge\NativeElementCollector;
use Native\Mobile\Edge\TailwindParser;
use Native\Mobile\UI\Elements\ListItem;

/**
* The opt-in trailing-slot props of `native:list-item`: `trailingTextStyle`
* and `trailingAlign`. Both are absent from the wire unless authored, so
* existing rows keep Material's defaults.
*/
beforeEach(function () {
NativeElementCollector::reset();
TailwindParser::clearCache();
ElementRegistry::reset();
ElementRegistry::register('list_item', ListItem::class);
});

afterEach(function () {
NativeElementCollector::reset();
ElementRegistry::reset();
});

function collectListItem(array $attrs): array
{
NativeElementCollector::leaf('list_item', ['headline' => 'Alice won', 'trailingText' => '48'] + $attrs);

return NativeElementCollector::collect()->toArray(new CallbackRegistry)['props'];
}

it('sends no trailing style or alignment unless authored', function () {
$props = collectListItem([]);

expect($props)->not->toHaveKey('trailing_text_style')
->and($props)->not->toHaveKey('trailing_align')
->and($props['trailing_type'])->toBe('text');
});

it('sends trailingTextStyle and trailingAlign as wire props', function () {
$props = collectListItem(['trailingTextStyle' => 'headline', 'trailingAlign' => 'center']);

expect($props['trailing_text_style'])->toBe('headline')
->and($props['trailing_align'])->toBe('center');
});

it('exposes the same props through the fluent builders', function () {
$item = ListItem::make()->trailingText('48')->trailingTextStyle('headline')->trailingAlign('center');

$props = $item->toArray(new CallbackRegistry)['props'];

expect($props['trailing_text_style'])->toBe('headline')
->and($props['trailing_align'])->toBe('center');
});

it('rejects unknown values', function () {
expect(fn () => ListItem::make()->trailingTextStyle('huge'))->toThrow(InvalidArgumentException::class)
->and(fn () => ListItem::make()->trailingAlign('bottom'))->toThrow(InvalidArgumentException::class);
});
Loading