|
|
|
// 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) {
|
|
|
|
switch (defaultTargetPlatform) {
|
|
|
|
case TargetPlatform.android:
|
|
|
|
return androidBuilder(context);
|
|
|
|
case TargetPlatform.iOS:
|
|
|
|
return iosBuilder(context);
|
|
|
|
default:
|
|
|
|
assert(false, 'Unexpected platform $defaultTargetPlatform');
|
|
|
|
return 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');
|
|
|
|
}
|
|
|
|
}
|