You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
samples/platform_design/lib/widgets.dart

361 lines
12 KiB

// Copyright 2020 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// A simple widget that builds different things on different platforms.
class PlatformWidget extends StatelessWidget {
const PlatformWidget({
super.key,
required this.androidBuilder,
required this.iosBuilder,
});
final WidgetBuilder androidBuilder;
final WidgetBuilder iosBuilder;
@override
Widget build(context) {
assert(
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS,
'Unexpected platform $defaultTargetPlatform');
return switch (defaultTargetPlatform) {
TargetPlatform.android => androidBuilder(context),
TargetPlatform.iOS => iosBuilder(context),
_ => const SizedBox.shrink()
};
}
}
/// A platform-agnostic card with a high elevation that reacts when tapped.
///
/// This is an example of a custom widget that an app developer might create for
/// use on both iOS and Android as part of their brand's unique design.
class PressableCard extends StatefulWidget {
const PressableCard({
this.onPressed,
required this.color,
required this.flattenAnimation,
this.child,
super.key,
});
final VoidCallback? onPressed;
final Color color;
final Animation<double> flattenAnimation;
final Widget? child;
@override
State<StatefulWidget> createState() => _PressableCardState();
}
class _PressableCardState extends State<PressableCard>
with SingleTickerProviderStateMixin {
bool pressed = false;
late final AnimationController controller;
late final Animation<double> elevationAnimation;
@override
void initState() {
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 40),
);
elevationAnimation =
controller.drive(CurveTween(curve: Curves.easeInOutCubic));
super.initState();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
double get flatten => 1 - widget.flattenAnimation.value;
@override
Widget build(context) {
return Listener(
onPointerDown: (details) {
if (widget.onPressed != null) {
controller.forward();
}
},
onPointerUp: (details) {
controller.reverse();
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
widget.onPressed?.call();
},
// This widget both internally drives an animation when pressed and
// responds to an external animation to flatten the card when in a
// hero animation. You likely want to modularize them more in your own
// app.
child: AnimatedBuilder(
animation:
Listenable.merge([elevationAnimation, widget.flattenAnimation]),
child: widget.child,
builder: (context, child) {
return Transform.scale(
// This is just a sample. You likely want to keep the math cleaner
// in your own app.
scale: 1 - elevationAnimation.value * 0.03,
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 16, horizontal: 16) *
flatten,
child: PhysicalModel(
elevation:
((1 - elevationAnimation.value) * 10 + 10) * flatten,
borderRadius: BorderRadius.circular(12 * flatten),
clipBehavior: Clip.antiAlias,
color: widget.color,
child: child,
),
),
);
},
),
),
);
}
}
/// A platform-agnostic card representing a song which can be in a card state,
/// a flat state or anything in between.
///
/// When it's in a card state, it's pressable.
///
/// This is an example of a custom widget that an app developer might create for
/// use on both iOS and Android as part of their brand's unique design.
class HeroAnimatingSongCard extends StatelessWidget {
const HeroAnimatingSongCard({
required this.song,
required this.color,
required this.heroAnimation,
this.onPressed,
super.key,
});
final String song;
final Color color;
final Animation<double> heroAnimation;
final VoidCallback? onPressed;
double get playButtonSize => 50 + 50 * heroAnimation.value;
@override
Widget build(context) {
// This is an inefficient usage of AnimatedBuilder since it's rebuilding
// the entire subtree instead of passing in a non-changing child and
// building a transition widget in between.
//
// Left simple in this demo because this card doesn't have any real inner
// content so this just rebuilds everything while animating.
return AnimatedBuilder(
animation: heroAnimation,
builder: (context, child) {
return PressableCard(
onPressed: heroAnimation.value == 0 ? onPressed : null,
color: color,
flattenAnimation: heroAnimation,
child: SizedBox(
height: 250,
child: Stack(
alignment: Alignment.center,
children: [
// The song title banner slides off in the hero animation.
Positioned(
bottom: -80 * heroAnimation.value,
left: 0,
right: 0,
child: Container(
height: 80,
color: Colors.black12,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
song,
style: const TextStyle(
fontSize: 21,
fontWeight: FontWeight.w500,
),
),
),
),
// The play button grows in the hero animation.
Padding(
padding: const EdgeInsets.only(bottom: 45) *
(1 - heroAnimation.value),
child: Container(
height: playButtonSize,
width: playButtonSize,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.black12,
),
alignment: Alignment.center,
child: Icon(Icons.play_arrow,
size: playButtonSize, color: Colors.black38),
),
),
],
),
),
);
},
);
}
}
/// A loading song tile's silhouette.
///
/// This is an example of a custom widget that an app developer might create for
/// use on both iOS and Android as part of their brand's unique design.
class SongPlaceholderTile extends StatelessWidget {
const SongPlaceholderTile({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 95,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
children: [
Container(
color: Theme.of(context).textTheme.bodyMedium!.color,
width: 130,
),
const Padding(
padding: EdgeInsets.only(left: 12),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 9,
margin: const EdgeInsets.only(right: 60),
color: Theme.of(context).textTheme.bodyMedium!.color,
),
Container(
height: 9,
margin: const EdgeInsets.only(right: 20, top: 8),
color: Theme.of(context).textTheme.bodyMedium!.color,
),
Container(
height: 9,
margin: const EdgeInsets.only(right: 40, top: 8),
color: Theme.of(context).textTheme.bodyMedium!.color,
),
Container(
height: 9,
margin: const EdgeInsets.only(right: 80, top: 8),
color: Theme.of(context).textTheme.bodyMedium!.color,
),
Container(
height: 9,
margin: const EdgeInsets.only(right: 50, top: 8),
color: Theme.of(context).textTheme.bodyMedium!.color,
),
],
),
),
],
),
),
);
}
}
// ===========================================================================
// Non-shared code below because different interfaces are shown to prompt
// for a multiple-choice answer.
//
// This is a design choice and you may want to do something different in your
// app.
// ===========================================================================
/// This uses a platform-appropriate mechanism to show users multiple choices.
///
/// On Android, it uses a dialog with radio buttons. On iOS, it uses a picker.
void showChoices(BuildContext context, List<String> choices) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
showDialog<void>(
context: context,
builder: (context) {
int? selectedRadio = 1;
return AlertDialog(
contentPadding: const EdgeInsets.only(top: 12),
content: StatefulBuilder(
builder: (context, setState) {
return Column(
mainAxisSize: MainAxisSize.min,
children: List<Widget>.generate(choices.length, (index) {
return RadioListTile<int?>(
title: Text(choices[index]),
value: index,
groupValue: selectedRadio,
onChanged: (value) {
setState(() => selectedRadio = value);
},
);
}),
);
},
),
actions: [
TextButton(
child: const Text('OK'),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: const Text('CANCEL'),
onPressed: () => Navigator.of(context).pop(),
),
],
);
},
);
return;
case TargetPlatform.iOS:
showCupertinoModalPopup<void>(
context: context,
builder: (context) {
return SizedBox(
height: 250,
child: CupertinoPicker(
backgroundColor: Theme.of(context).canvasColor,
useMagnifier: true,
magnification: 1.1,
itemExtent: 40,
scrollController: FixedExtentScrollController(initialItem: 1),
children: List<Widget>.generate(choices.length, (index) {
return Center(
child: Text(
choices[index],
style: const TextStyle(
fontSize: 21,
),
),
);
}),
onSelectedItemChanged: (value) {},
),
);
},
);
return;
default:
assert(false, 'Unexpected platform $defaultTargetPlatform');
}
}