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
9 changes: 6 additions & 3 deletions resources/android/ListItemRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ object ListItemRenderer {
iconBgColor = leadingIconBgColor,
onChangeCb = onLeadingChangeCb,
nodeId = node.id,
disabled = disabled
disabled = disabled,
imageFit = p.getString("leading_image_fit")
),
trailingContent = run {
// Multi-badge stack (e.g. flag + pin both visible)
Expand Down Expand Up @@ -198,7 +199,8 @@ object ListItemRenderer {
iconBgColor: Int,
onChangeCb: Int,
nodeId: Int,
disabled: Boolean
disabled: Boolean,
imageFit: String = ""
): (@Composable () -> Unit)? {
// Determine effective type — backward compat: fall back to icon if leading_type empty
val effectiveType = type.ifEmpty {
Expand Down Expand Up @@ -291,7 +293,8 @@ object ListItemRenderer {
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
// `contain` letterboxes instead of cropping to the square.
contentScale = if (imageFit == "contain") ContentScale.Fit else ContentScale.Crop,
loading = {
Box(
modifier = Modifier
Expand Down
8 changes: 5 additions & 3 deletions resources/ios/NativeUIImageSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,12 @@ enum NativeUIImageCache {
/// resolve. Callers supply their own frame and clip shape.
struct NativeUIRowImage<Placeholder: View>: View {
private let src: String
private let contentMode: ContentMode
private let placeholder: () -> Placeholder

init(src: String, @ViewBuilder placeholder: @escaping () -> Placeholder) {
init(src: String, contentMode: ContentMode = .fill, @ViewBuilder placeholder: @escaping () -> Placeholder) {
self.src = src
self.contentMode = contentMode
self.placeholder = placeholder
}

Expand All @@ -123,15 +125,15 @@ struct NativeUIRowImage<Placeholder: View>: View {
if let uiImage = NativeUIImageCache.image(atPath: path) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.aspectRatio(contentMode: contentMode)
} else {
placeholder()
}
case .remote(let url):
AsyncImage(url: url) { image in
image
.resizable()
.scaledToFill()
.aspectRatio(contentMode: contentMode)
} placeholder: {
placeholder()
}
Expand Down
8 changes: 5 additions & 3 deletions resources/ios/NativeUIListItemRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ struct NativeUIListItemRenderer: View {
monogramColor: leadingMonogramColor,
iconBgColor: leadingIconBgColor,
checked: leadingChecked,
changeCb: onLeadingChangeCb
changeCb: onLeadingChangeCb,
imageFit: p.getString("leading_image_fit")
)

// Text content
Expand Down Expand Up @@ -124,7 +125,7 @@ struct NativeUIListItemRenderer: View {
}

@ViewBuilder
private func buildLeadingContent(type: String, value: String, monogramColor: Int, iconBgColor: Int = 0, checked: Bool = false, changeCb: Int = 0) -> some View {
private func buildLeadingContent(type: String, value: String, monogramColor: Int, iconBgColor: Int = 0, checked: Bool = false, changeCb: Int = 0, imageFit: String = "") -> some View {
switch type {
case "icon":
if iconBgColor != 0 {
Expand Down Expand Up @@ -161,7 +162,8 @@ struct NativeUIListItemRenderer: View {
.accessibilityHidden(true)
case "image":
// Decorative — the row's text content carries the meaning.
NativeUIRowImage(src: value) {
// `contain` letterboxes instead of cropping to the square.
NativeUIRowImage(src: value, contentMode: imageFit == "contain" ? .fit : .fill) {
RoundedRectangle(cornerRadius: 4).fill(Color(.systemGray5))
}
.frame(width: 56, height: 56)
Expand Down
14 changes: 12 additions & 2 deletions src/Elements/ListItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public function applyAttributes(array $attrs): void
$this->leadingIconBackgroundColor($attrs['leadingIconBgColor']);
}
if (isset($attrs['leadingImage'])) {
$this->leadingImage($attrs['leadingImage']);
$this->leadingImage($attrs['leadingImage'], $attrs['leadingImageFit'] ?? $attrs['leading-image-fit'] ?? null);
}
if (isset($attrs['leadingCheckbox'])) {
$this->leadingCheckbox((bool) $attrs['leadingCheckbox']);
Expand Down Expand Up @@ -333,11 +333,21 @@ public function leadingMonogram(string $initials, ?string $color = null): static
return $this;
}

public function leadingImage(string $url): static
/**
* Leading thumbnail, drawn in a 56pt square. `$fit` is `cover` (the
* default: fill the square, cropping) or `contain` (letterbox), for
* artwork whose aspect ratio carries the meaning — a wide illustration,
* a logo. Blade: `leadingImageFit="contain"`.
*/
public function leadingImage(string $url, ?string $fit = null): static
{
$this->listItemProps['leading_type'] = 'image';
$this->listItemProps['leading_value'] = ImageSource::forDevice($url);

if ($fit !== null && $fit !== '') {
$this->listItemProps['leading_image_fit'] = $fit;
}

return $this;
}

Expand Down
33 changes: 33 additions & 0 deletions tests/ListItemLeadingImageFitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

use Native\Mobile\Edge\CallbackRegistry;
use Native\Mobile\UI\Elements\ListItem;

function leadingImageProps(ListItem $item): array
{
return $item->toArray(new CallbackRegistry)['props'];
}

it('crops to the square by default', function () {
$item = new ListItem;
$item->applyAttributes(['headline' => 'Row', 'leadingImage' => 'https://example.com/a.png']);

expect(leadingImageProps($item))->not->toHaveKey('leading_image_fit');
});

it('resolves the fit attribute in both spellings', function (string $attribute) {
$item = new ListItem;
$item->applyAttributes(['headline' => 'Row', 'leadingImage' => 'https://example.com/a.png', $attribute => 'contain']);

expect(leadingImageProps($item)['leading_image_fit'])->toBe('contain');
})->with(['leadingImageFit', 'leading-image-fit']);

it('ignores an empty fit', function () {
expect(leadingImageProps(ListItem::make()->leadingImage('https://example.com/a.png', '')))
->not->toHaveKey('leading_image_fit');
});

it('is settable fluently', function () {
expect(leadingImageProps(ListItem::make()->leadingImage('https://example.com/a.png', 'contain'))['leading_image_fit'])
->toBe('contain');
});
Loading