Skip to content

Product Tile

This is an interesting layout I stumbled upon when searching StackOverflow, it contains Title, Seller, and Info widgets arranged so that the Seller is positioned above the space between Title and Info.

Normally for something like this you would use Stack, but that unfortunately doesn't work if each widget is interactable and has a dynamic size.

Complete Example

class MyWidget extends StatelessWidget {
  const MyWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ProductTile(
      title: Container(
        decoration: BoxDecoration(
          color: const Color(0xff4881bd),
          borderRadius: BorderRadius.circular(8.0),
        ),
        width: 350,
        height: 200,
        alignment: Alignment.center,
        child: const Text('Title'),
      ),
      seller: Container(
        decoration: BoxDecoration(
          color: const Color(0xff5148bd),
          border: Border.all(color: darkBlue, width: 8),
          shape: BoxShape.circle,
        ),
        alignment: Alignment.center,
        child: const Text('Seller'),
        width: 64,
        height: 64,
      ),
      info: Container(
        decoration: BoxDecoration(
          color: const Color(0xff5148bd),
          borderRadius: BorderRadius.circular(8.0),
        ),
        alignment: Alignment.center,
        child: const Text('Info'),
        padding: const EdgeInsets.all(16.0),
      ),
    );
  }
}

@immutable
class ProductTileStyle {
  /// How far to the left the seller is inset
  final double sellerInset;

  /// The size of the gap between the title and description
  final double gapHeight;

  const ProductTileStyle({
    this.sellerInset = 16.0,
    this.gapHeight = 8.0,
  });

  @override
  bool operator ==(Object? other) =>
      identical(this, other) ||
      (other is ProductTileStyle &&
          other.sellerInset == sellerInset &&
          other.gapHeight == gapHeight);

  @override
  int get hashCode => hashValues(sellerInset, gapHeight);
}

class ProductTile extends StatelessWidget {
  final Widget title;
  final Widget info;
  final Widget seller;
  final ProductTileStyle style;

  const ProductTile({
    Key? key,
    required this.title,
    required this.info,
    required this.seller,
    this.style = const ProductTileStyle(),
  }) : super(key: key);

  @override
  Widget build(context) {
    return CustomBoxy(
      delegate: ProductTileDelegate(style: style),
      children: [
        // Children are in paint order, put the seller last so it can sit
        // above the others
        BoxyId(id: #title, child: title),
        BoxyId(id: #info, child: info),
        BoxyId(id: #seller, child: seller),
      ],
    );
  }
}

class ProductTileDelegate extends BoxyDelegate {
  final ProductTileStyle style;

  ProductTileDelegate({required this.style});

  @override
  Size layout() {
    // We can grab children by name using BoxyId and getChild
    final title = getChild(#title);
    final seller = getChild(#seller);
    final info = getChild(#info);

    // Lay out the seller first so it can provide a minimum height to the title
    // and info
    final sellerSize = seller.layout(constraints.deflate(
      EdgeInsets.only(right: style.sellerInset),
    ));

    // Lay out and position the title
    final titleSize = title.layout(constraints.copyWith(
      minHeight: sellerSize.height / 2 + style.gapHeight / 2,
    ));
    title.position(Offset.zero);

    // Position the seller at the bottom right of the title, offset to the left
    // by sellerInset
    seller.position(Offset(
      titleSize.width - (sellerSize.width + style.sellerInset),
      (titleSize.height - sellerSize.height / 2) + style.gapHeight / 2,
    ));

    // Lay out info to match the width of title and position it below the title
    final infoSize = info.layout(BoxConstraints(
      minHeight: sellerSize.height / 2,
      minWidth: titleSize.width,
      maxWidth: titleSize.width,
    ));
    info.position(Offset(0, titleSize.height + style.gapHeight));

    return Size(
      titleSize.width,
      titleSize.height + infoSize.height + style.gapHeight,
    );
  }

  // Any BoxyDelegate with parameters should always implement shouldRelaout,
  // otherwise it won't update when its properties do.
  @override
  bool shouldRelayout(ProductTileDelegate oldDelegate) =>
      style != oldDelegate.style;
}

With some images and text, we get a finished product tile that is fully adaptive:

default state

expanded title

expanded info

expanded seller

constrained width