diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt index 0cf7841..fdfd31b 100644 --- a/resources/android/ListItemRenderer.kt +++ b/resources/android/ListItemRenderer.kt @@ -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) @@ -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 { @@ -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 diff --git a/resources/ios/NativeUIImageSource.swift b/resources/ios/NativeUIImageSource.swift index 5703803..b236466 100644 --- a/resources/ios/NativeUIImageSource.swift +++ b/resources/ios/NativeUIImageSource.swift @@ -110,10 +110,12 @@ enum NativeUIImageCache { /// resolve. Callers supply their own frame and clip shape. struct NativeUIRowImage: 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 } @@ -123,7 +125,7 @@ struct NativeUIRowImage: View { if let uiImage = NativeUIImageCache.image(atPath: path) { Image(uiImage: uiImage) .resizable() - .scaledToFill() + .aspectRatio(contentMode: contentMode) } else { placeholder() } @@ -131,7 +133,7 @@ struct NativeUIRowImage: View { AsyncImage(url: url) { image in image .resizable() - .scaledToFill() + .aspectRatio(contentMode: contentMode) } placeholder: { placeholder() } diff --git a/resources/ios/NativeUIListItemRenderer.swift b/resources/ios/NativeUIListItemRenderer.swift index 534cf7f..e926d93 100644 --- a/resources/ios/NativeUIListItemRenderer.swift +++ b/resources/ios/NativeUIListItemRenderer.swift @@ -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 @@ -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 { @@ -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) diff --git a/src/Elements/ListItem.php b/src/Elements/ListItem.php index d5b2ace..5c6059b 100644 --- a/src/Elements/ListItem.php +++ b/src/Elements/ListItem.php @@ -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']); @@ -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; } diff --git a/tests/ListItemLeadingImageFitTest.php b/tests/ListItemLeadingImageFitTest.php new file mode 100644 index 0000000..c355f50 --- /dev/null +++ b/tests/ListItemLeadingImageFitTest.php @@ -0,0 +1,33 @@ +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'); +});