snapshot of https://github.com/flutter/samples.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Main Branch CI / Test Flutter beta on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter stable on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter beta on macos-latest (push) Has been cancelled
Main Branch CI / Test Flutter stable on macos-latest (push) Has been cancelled
Some checks failed
Main Branch CI / Test Flutter beta on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter stable on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter beta on macos-latest (push) Has been cancelled
Main Branch CI / Test Flutter stable on macos-latest (push) Has been cancelled
This commit is contained in:
373
platform_design/lib/widgets.dart
Normal file
373
platform_design/lib/widgets.dart
Normal file
@@ -0,0 +1,373 @@
|
||||
// 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 RadioGroup(
|
||||
groupValue: selectedRadio,
|
||||
onChanged: (value) {
|
||||
setState(() => selectedRadio = value);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List<Widget>.generate(choices.length, (
|
||||
index,
|
||||
) {
|
||||
return RadioListTile<int?>(
|
||||
title: Text(choices[index]),
|
||||
value: index,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user