diff --git a/examples/flutter_gallery/lib/demo/material/cards_demo.dart b/examples/flutter_gallery/lib/demo/material/cards_demo.dart index 909a5bd0b6..6898ff7468 100644 --- a/examples/flutter_gallery/lib/demo/material/cards_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/cards_demo.dart @@ -9,152 +9,338 @@ import '../../gallery/demo.dart'; const String _kGalleryAssetsPackage = 'flutter_gallery_assets'; +enum CardDemoType { + standard, + tappable, + selectable, +} + class TravelDestination { const TravelDestination({ - this.assetName, - this.assetPackage, - this.title, - this.description, - }); + @required this.assetName, + @required this.assetPackage, + @required this.title, + @required this.description, + @required this.city, + @required this.location, + this.type = CardDemoType.standard, + }) : assert(assetName != null), + assert(assetPackage != null), + assert(title != null), + assert(description != null), + assert(city != null), + assert(location != null); final String assetName; final String assetPackage; final String title; - final List description; - - bool get isValid => assetName != null && title != null && description?.length == 3; + final String description; + final String city; + final String location; + final CardDemoType type; } -final List destinations = [ - const TravelDestination( +const List destinations = [ + TravelDestination( assetName: 'places/india_thanjavur_market.png', assetPackage: _kGalleryAssetsPackage, title: 'Top 10 Cities to Visit in Tamil Nadu', - description: [ - 'Number 10', - 'Thanjavur', - 'Thanjavur, Tamil Nadu', - ], + description: 'Number 10', + city: 'Thanjavur', + location: 'Thanjavur, Tamil Nadu', ), - const TravelDestination( + TravelDestination( assetName: 'places/india_chettinad_silk_maker.png', assetPackage: _kGalleryAssetsPackage, title: 'Artisans of Southern India', - description: [ - 'Silk Spinners', - 'Chettinad', - 'Sivaganga, Tamil Nadu', - ], + description: 'Silk Spinners', + city: 'Chettinad', + location: 'Sivaganga, Tamil Nadu', + type: CardDemoType.tappable, + ), + TravelDestination( + assetName: 'places/india_tanjore_thanjavur_temple.png', + assetPackage: _kGalleryAssetsPackage, + title: 'Brihadisvara Temple', + description: 'Temples', + city: 'Thanjavur', + location: 'Thanjavur, Tamil Nadu', + type: CardDemoType.selectable, ) ]; class TravelDestinationItem extends StatelessWidget { - TravelDestinationItem({ Key key, @required this.destination, this.shape }) - : assert(destination != null && destination.isValid), + const TravelDestinationItem({ Key key, @required this.destination, this.shape }) + : assert(destination != null), super(key: key); - static const double height = 366.0; + // This height will allow for all the Card's content to fit comfortably within the card. + static const double height = 338.0; final TravelDestination destination; final ShapeBorder shape; + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + const SectionTitle(title: 'Normal'), + SizedBox( + height: height, + child: Card( + // This ensures that the Card's children are clipped correctly. + clipBehavior: Clip.antiAlias, + shape: shape, + child: TravelDestinationContent(destination: destination), + ), + ), + ], + ), + ), + ); + } +} + +class TappableTravelDestinationItem extends StatelessWidget { + const TappableTravelDestinationItem({ Key key, @required this.destination, this.shape }) + : assert(destination != null), + super(key: key); + + // This height will allow for all the Card's content to fit comfortably within the card. + static const double height = 298.0; + final TravelDestination destination; + final ShapeBorder shape; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + const SectionTitle(title: 'Tappable'), + SizedBox( + height: height, + child: Card( + // This ensures that the Card's children (including the ink splash) are clipped correctly. + clipBehavior: Clip.antiAlias, + shape: shape, + child: InkWell( + onTap: () { + print('Card was tapped'); + }, + splashColor: Theme.of(context).colorScheme.primary.withAlpha(30), + child: TravelDestinationContent(destination: destination), + ), + ), + ), + ], + ), + ), + ); + } +} + +class SelectableTravelDestinationItem extends StatefulWidget { + const SelectableTravelDestinationItem({ Key key, @required this.destination, this.shape }) + : assert(destination != null), + super(key: key); + + final TravelDestination destination; + final ShapeBorder shape; + + @override + _SelectableTravelDestinationItemState createState() => _SelectableTravelDestinationItemState(); +} + +class _SelectableTravelDestinationItemState extends State { + + // This height will allow for all the Card's content to fit comfortably within the card. + static const double height = 298.0; + bool _isSelected = false; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + const SectionTitle(title: 'Selectable (long press)'), + SizedBox( + height: height, + child: Card( + // This ensures that the Card's children (including the ink splash) are clipped correctly. + clipBehavior: Clip.antiAlias, + shape: widget.shape, + child: InkWell( + onLongPress: () { + print('Selectable card state changed'); + setState(() { + _isSelected = !_isSelected; + }); + }, + splashColor: Theme.of(context).colorScheme.primary.withAlpha(30), + child: Stack( + children: [ + Container( + color: _isSelected + ? Theme.of(context).colorScheme.primary.withAlpha(41) + : Colors.transparent, + ), + TravelDestinationContent(destination: widget.destination), + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Icon( + Icons.check_circle, + color: _isSelected ? Colors.white : Colors.transparent, + ), + ) + ), + ], + ) + ), + ), + ), + ], + ), + ), + ); + } +} + +class SectionTitle extends StatelessWidget { + const SectionTitle({ + Key key, + this.title + }) : super(key: key); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4.0, 4.0, 4.0, 12.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text(title, style: Theme.of(context).textTheme.subhead), + ), + ); + } +} + +class TravelDestinationContent extends StatelessWidget { + const TravelDestinationContent({ Key key, @required this.destination }) + : assert(destination != null), + super(key: key); + + final TravelDestination destination; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final TextStyle titleStyle = theme.textTheme.headline.copyWith(color: Colors.white); final TextStyle descriptionStyle = theme.textTheme.subhead; - return SafeArea( - top: false, - bottom: false, - child: Container( - padding: const EdgeInsets.all(8.0), - height: height, - child: Card( - clipBehavior: Clip.antiAlias, - shape: shape, + final List children = [ + // Photo and title. + SizedBox( + height: 184.0, + child: Stack( + children: [ + Positioned.fill( + // In order to have the ink splash appear above the image, you + // must use Ink.image. This allows the image to be painted as part + // of the Material and display ink effects above it. Using a + // standard Image will obscure the ink splash. + child: Ink.image( + image: AssetImage(destination.assetName, package: destination.assetPackage), + fit: BoxFit.cover, + child: Container(), + ) + ), + Positioned( + bottom: 16.0, + left: 16.0, + right: 16.0, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + destination.title, + style: titleStyle, + ), + ), + ), + ], + ), + ), + // Description and share/explore buttons. + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0), + child: DefaultTextStyle( + softWrap: false, + overflow: TextOverflow.ellipsis, + style: descriptionStyle, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // photo and title - SizedBox( - height: 184.0, - child: Stack( - children: [ - Positioned.fill( - child: Image.asset( - destination.assetName, - package: destination.assetPackage, - fit: BoxFit.cover, - ), - ), - Positioned( - bottom: 16.0, - left: 16.0, - right: 16.0, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text(destination.title, - style: titleStyle, - ), - ), - ), - ], - ), - ), - // description and share/explore buttons - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0), - child: DefaultTextStyle( - softWrap: false, - overflow: TextOverflow.ellipsis, - style: descriptionStyle, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // three line description - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - destination.description[0], - style: descriptionStyle.copyWith(color: Colors.black54), - ), - ), - Text(destination.description[1]), - Text(destination.description[2]), - ], - ), - ), - ), - ), - // share, explore buttons - ButtonTheme.bar( - child: ButtonBar( - alignment: MainAxisAlignment.start, - children: [ - FlatButton( - child: Text('SHARE', semanticsLabel: 'Share ${destination.title}'), - textColor: Colors.amber.shade500, - onPressed: () { /* do nothing */ }, - ), - FlatButton( - child: Text('EXPLORE', semanticsLabel: 'Explore ${destination.title}'), - textColor: Colors.amber.shade500, - onPressed: () { /* do nothing */ }, - ), - ], + // three line description + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + destination.description, + style: descriptionStyle.copyWith(color: Colors.black54), ), ), + Text(destination.city), + Text(destination.location), ], ), ), ), + ]; + + if (destination.type == CardDemoType.standard) { + children.add( + // share, explore buttons + ButtonTheme.bar( + child: ButtonBar( + alignment: MainAxisAlignment.start, + children: [ + FlatButton( + child: Text('SHARE', semanticsLabel: 'Share ${destination.title}'), + textColor: Colors.amber.shade500, + onPressed: () { print('pressed'); }, + ), + FlatButton( + child: Text('EXPLORE', semanticsLabel: 'Explore ${destination.title}'), + textColor: Colors.amber.shade500, + onPressed: () { print('pressed'); }, + ), + ], + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, ); } } - class CardsDemo extends StatefulWidget { static const String routeName = '/material/cards'; @@ -169,7 +355,7 @@ class _CardsDemoState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Travel stream'), + title: const Text('Cards'), actions: [ MaterialDemoDocumentationButton(CardsDemo.routeName), IconButton( @@ -193,15 +379,24 @@ class _CardsDemoState extends State { ], ), body: ListView( - itemExtent: TravelDestinationItem.height, padding: const EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0), children: destinations.map((TravelDestination destination) { + Widget child; + switch (destination.type) { + case CardDemoType.standard: + child = TravelDestinationItem(destination: destination, shape: _shape); + break; + case CardDemoType.tappable: + child = TappableTravelDestinationItem(destination: destination, shape: _shape); + break; + case CardDemoType.selectable: + child = SelectableTravelDestinationItem(destination: destination, shape: _shape); + break; + } + return Container( margin: const EdgeInsets.only(bottom: 8.0), - child: TravelDestinationItem( - destination: destination, - shape: _shape, - ), + child: child, ); }).toList() ) diff --git a/packages/flutter/lib/src/material/card.dart b/packages/flutter/lib/src/material/card.dart index 1cd291f110..2215cb352e 100644 --- a/packages/flutter/lib/src/material/card.dart +++ b/packages/flutter/lib/src/material/card.dart @@ -56,6 +56,27 @@ import 'theme.dart'; /// ``` /// {@end-tool} /// +/// Sometimes the primary action area of a card is the card itself. Cards can be +/// one large touch target that shows a detail screen when tapped. +/// +/// {@tool snippet --template=stateless_widget} +/// +/// This sample shows creation of a [Card] widget that can be tapped. When +/// tapped this [Card]'s [InkWell] displays an "ink splash" that fills the +/// entire card. +/// +/// ```dart +/// Card( +/// child: InkWell( +/// splashColor: Colors.blue.withAlpha(30), +/// onTap: () { /* ... */ }, +/// child: Text('A card that can be tapped'), +/// ), +/// ) +/// ``` +/// +/// {@end-tool} +/// /// See also: /// /// * [ListTile], to display icons and text in a card.