如何创建扩展到全屏的可拖动元素
How to create a draggable element that expands to full screen
我想在 flutter 中构建一个典型的音频播放器,它在屏幕底部有一个可见的迷你播放器,可以拖动或点击它以变成全屏,就像内置的 iOS 音乐应用程序一样或 spotify:
顶部的元素不能在两者之间停止,所以如果用户拖动到一半,那么它应该再次向下移动,或者完成转换(取决于它当前所在的位置)。
再往下拉应该也可以吧
旁注:我使用的是 CupertinoApp
而不是 MaterialApp
... 不确定这是否有所不同。
你试过背景元素吗?您可以查看此 really cool article 以了解如何实现它。您可以根据需要对其进行自定义和使用。我用下面的例子粗略地实现了它,我正在使用 CupertinoApp
(这是我第一次):)
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:meta/meta.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CupertinoApp(
title: 'Flutter Demo',
routes: {
'/': (context) => SimpleExample(),
},
);
}
}
class SimpleExample extends StatelessWidget {
@override
Widget build(BuildContext context) =>
Scaffold(body: SafeArea(child: Panels()));
}
class Panels extends StatelessWidget {
final frontPanelVisible = ValueNotifier<bool>(false);
@override
Widget build(BuildContext context) {
return Backdrop(
frontLayer: FrontPanel(),
backLayer: BackPanel(
frontPanelOpen: frontPanelVisible,
),
frontHeader: FrontPanelTitle(),
panelVisible: frontPanelVisible,
frontPanelOpenHeight: 40.0,
frontHeaderHeight: 48.0,
frontHeaderVisibleClosed: true,
);
}
}
class FrontPanelTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 16.0, left: 16.0),
child: Text(
'Tap Me',
style: Theme.of(context).textTheme.subhead,
),
);
}
}
class FrontPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).cardColor,
child: Center(child: Text('Hello world')));
}
}
class BackPanel extends StatefulWidget {
BackPanel({@required this.frontPanelOpen});
final ValueNotifier<bool> frontPanelOpen;
@override
createState() => _BackPanelState();
}
class _BackPanelState extends State<BackPanel> {
bool panelOpen;
@override
initState() {
super.initState();
panelOpen = widget.frontPanelOpen.value;
widget.frontPanelOpen.addListener(_subscribeToValueNotifier);
}
void _subscribeToValueNotifier() =>
setState(() => panelOpen = widget.frontPanelOpen.value);
/// Required for resubscribing when hot reload occurs
@override
void didUpdateWidget(BackPanel oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.frontPanelOpen.removeListener(_subscribeToValueNotifier);
widget.frontPanelOpen.addListener(_subscribeToValueNotifier);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Padding(
padding: const EdgeInsets.only(
top: 10.0,
),
child: Text('Front panel is ${panelOpen ? "open" : "closed"}'),
)),
Center(
child: RaisedButton(
child: Text('Tap Me'),
onPressed: () {
widget.frontPanelOpen.value = true;
},
)),
// will not be seen; covered by front panel
Center(child: Text('Bottom of Panel')),
]);
}
}
const _kFlingVelocity = 2.0;
class _BackdropPanel extends StatelessWidget {
const _BackdropPanel({
Key key,
this.onTap,
this.onVerticalDragUpdate,
this.onVerticalDragEnd,
this.title,
this.child,
this.titleHeight,
this.padding,
}) : super(key: key);
final VoidCallback onTap;
final GestureDragUpdateCallback onVerticalDragUpdate;
final GestureDragEndCallback onVerticalDragEnd;
final Widget title;
final Widget child;
final double titleHeight;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Material(
elevation: 12.0,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: onVerticalDragUpdate,
onVerticalDragEnd: onVerticalDragEnd,
onTap: onTap,
child: Container(height: titleHeight, child: title),
),
Divider(
height: 1.0,
),
Expanded(
child: child,
),
],
),
),
);
}
}
/// Builds a Backdrop.
///
/// A Backdrop widget has two panels, front and back. The front panel is shown
/// by default, and slides down to show the back panel, from which a user
/// can make a selection. The user can also configure the titles for when the
/// front or back panel is showing.
class Backdrop extends StatefulWidget {
final Widget frontLayer;
final Widget backLayer;
final Widget frontHeader;
final double frontPanelOpenHeight;
final double frontHeaderHeight;
final bool frontHeaderVisibleClosed;
final EdgeInsets frontPanelPadding;
final ValueNotifier<bool> panelVisible;
Backdrop(
{@required this.frontLayer,
@required this.backLayer,
this.frontPanelOpenHeight = 0.0,
this.frontHeaderHeight = 48.0,
this.frontPanelPadding = const EdgeInsets.all(0.0),
this.frontHeaderVisibleClosed = true,
this.panelVisible,
this.frontHeader})
: assert(frontLayer != null),
assert(backLayer != null);
@override
createState() => _BackdropState();
}
class _BackdropState extends State<Backdrop>
with SingleTickerProviderStateMixin {
final _backdropKey = GlobalKey(debugLabel: 'Backdrop');
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 300),
// value of 0 hides the panel; value of 1 fully shows the panel
value: (widget.panelVisible?.value ?? true) ? 1.0 : 0.0,
vsync: this,
);
// Listen on the toggle value notifier if it's not null
widget.panelVisible?.addListener(_subscribeToValueNotifier);
// Ensure that the value notifier is updated when the panel is opened or closed
if (widget.panelVisible != null) {
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed)
widget.panelVisible.value = true;
else if (status == AnimationStatus.dismissed)
widget.panelVisible.value = false;
});
}
}
void _subscribeToValueNotifier() {
if (widget.panelVisible.value != _backdropPanelVisible)
_toggleBackdropPanelVisibility();
}
/// Required for resubscribing when hot reload occurs
@override
void didUpdateWidget(Backdrop oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.panelVisible?.removeListener(_subscribeToValueNotifier);
widget.panelVisible?.addListener(_subscribeToValueNotifier);
}
@override
void dispose() {
_controller.dispose();
widget.panelVisible?.dispose();
super.dispose();
}
bool get _backdropPanelVisible =>
_controller.status == AnimationStatus.completed ||
_controller.status == AnimationStatus.forward;
void _toggleBackdropPanelVisibility() => _controller.fling(
velocity: _backdropPanelVisible ? -_kFlingVelocity : _kFlingVelocity);
double get _backdropHeight {
final RenderBox renderBox = _backdropKey.currentContext.findRenderObject();
return renderBox.size.height;
}
void _handleDragUpdate(DragUpdateDetails details) {
if (!_controller.isAnimating)
_controller.value -= details.primaryDelta / _backdropHeight;
}
void _handleDragEnd(DragEndDetails details) {
if (_controller.isAnimating ||
_controller.status == AnimationStatus.completed) return;
final double flingVelocity =
details.velocity.pixelsPerSecond.dy / _backdropHeight;
if (flingVelocity < 0.0)
_controller.fling(velocity: math.max(_kFlingVelocity, -flingVelocity));
else if (flingVelocity > 0.0)
_controller.fling(velocity: math.min(-_kFlingVelocity, -flingVelocity));
else
_controller.fling(
velocity:
_controller.value < 0.5 ? -_kFlingVelocity : _kFlingVelocity);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final panelSize = constraints.biggest;
final closedPercentage = widget.frontHeaderVisibleClosed
? (panelSize.height - widget.frontHeaderHeight) / panelSize.height
: 1.0;
final openPercentage = widget.frontPanelOpenHeight / panelSize.height;
final panelDetailsPosition = Tween<Offset>(
begin: Offset(0.0, closedPercentage),
end: Offset(0.0, openPercentage),
).animate(_controller.view);
return Container(
key: _backdropKey,
child: Stack(
children: <Widget>[
widget.backLayer,
SlideTransition(
position: panelDetailsPosition,
child: _BackdropPanel(
onTap: _toggleBackdropPanelVisibility,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
title: widget.frontHeader,
titleHeight: widget.frontHeaderHeight,
child: widget.frontLayer,
padding: widget.frontPanelPadding,
),
),
],
),
);
});
}
}
我强烈推荐这个套餐miniplayer 0.6.1
它基本上包含您需要的一切,还提供可定制的迷你和完整播放器。但是你将不得不阅读本机代码并进行一些更改。
除此之外,动画部分也可以使用Flutter Heroes。它也适用于这种情况。
您可以使用 DraggableScrollableSheet widget. Also this article 将帮助您开始使用小部件。
我想在 flutter 中构建一个典型的音频播放器,它在屏幕底部有一个可见的迷你播放器,可以拖动或点击它以变成全屏,就像内置的 iOS 音乐应用程序一样或 spotify:
顶部的元素不能在两者之间停止,所以如果用户拖动到一半,那么它应该再次向下移动,或者完成转换(取决于它当前所在的位置)。
再往下拉应该也可以吧
旁注:我使用的是 CupertinoApp
而不是 MaterialApp
... 不确定这是否有所不同。
你试过背景元素吗?您可以查看此 really cool article 以了解如何实现它。您可以根据需要对其进行自定义和使用。我用下面的例子粗略地实现了它,我正在使用 CupertinoApp
(这是我第一次):)
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:meta/meta.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CupertinoApp(
title: 'Flutter Demo',
routes: {
'/': (context) => SimpleExample(),
},
);
}
}
class SimpleExample extends StatelessWidget {
@override
Widget build(BuildContext context) =>
Scaffold(body: SafeArea(child: Panels()));
}
class Panels extends StatelessWidget {
final frontPanelVisible = ValueNotifier<bool>(false);
@override
Widget build(BuildContext context) {
return Backdrop(
frontLayer: FrontPanel(),
backLayer: BackPanel(
frontPanelOpen: frontPanelVisible,
),
frontHeader: FrontPanelTitle(),
panelVisible: frontPanelVisible,
frontPanelOpenHeight: 40.0,
frontHeaderHeight: 48.0,
frontHeaderVisibleClosed: true,
);
}
}
class FrontPanelTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 16.0, left: 16.0),
child: Text(
'Tap Me',
style: Theme.of(context).textTheme.subhead,
),
);
}
}
class FrontPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).cardColor,
child: Center(child: Text('Hello world')));
}
}
class BackPanel extends StatefulWidget {
BackPanel({@required this.frontPanelOpen});
final ValueNotifier<bool> frontPanelOpen;
@override
createState() => _BackPanelState();
}
class _BackPanelState extends State<BackPanel> {
bool panelOpen;
@override
initState() {
super.initState();
panelOpen = widget.frontPanelOpen.value;
widget.frontPanelOpen.addListener(_subscribeToValueNotifier);
}
void _subscribeToValueNotifier() =>
setState(() => panelOpen = widget.frontPanelOpen.value);
/// Required for resubscribing when hot reload occurs
@override
void didUpdateWidget(BackPanel oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.frontPanelOpen.removeListener(_subscribeToValueNotifier);
widget.frontPanelOpen.addListener(_subscribeToValueNotifier);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Padding(
padding: const EdgeInsets.only(
top: 10.0,
),
child: Text('Front panel is ${panelOpen ? "open" : "closed"}'),
)),
Center(
child: RaisedButton(
child: Text('Tap Me'),
onPressed: () {
widget.frontPanelOpen.value = true;
},
)),
// will not be seen; covered by front panel
Center(child: Text('Bottom of Panel')),
]);
}
}
const _kFlingVelocity = 2.0;
class _BackdropPanel extends StatelessWidget {
const _BackdropPanel({
Key key,
this.onTap,
this.onVerticalDragUpdate,
this.onVerticalDragEnd,
this.title,
this.child,
this.titleHeight,
this.padding,
}) : super(key: key);
final VoidCallback onTap;
final GestureDragUpdateCallback onVerticalDragUpdate;
final GestureDragEndCallback onVerticalDragEnd;
final Widget title;
final Widget child;
final double titleHeight;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Material(
elevation: 12.0,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: onVerticalDragUpdate,
onVerticalDragEnd: onVerticalDragEnd,
onTap: onTap,
child: Container(height: titleHeight, child: title),
),
Divider(
height: 1.0,
),
Expanded(
child: child,
),
],
),
),
);
}
}
/// Builds a Backdrop.
///
/// A Backdrop widget has two panels, front and back. The front panel is shown
/// by default, and slides down to show the back panel, from which a user
/// can make a selection. The user can also configure the titles for when the
/// front or back panel is showing.
class Backdrop extends StatefulWidget {
final Widget frontLayer;
final Widget backLayer;
final Widget frontHeader;
final double frontPanelOpenHeight;
final double frontHeaderHeight;
final bool frontHeaderVisibleClosed;
final EdgeInsets frontPanelPadding;
final ValueNotifier<bool> panelVisible;
Backdrop(
{@required this.frontLayer,
@required this.backLayer,
this.frontPanelOpenHeight = 0.0,
this.frontHeaderHeight = 48.0,
this.frontPanelPadding = const EdgeInsets.all(0.0),
this.frontHeaderVisibleClosed = true,
this.panelVisible,
this.frontHeader})
: assert(frontLayer != null),
assert(backLayer != null);
@override
createState() => _BackdropState();
}
class _BackdropState extends State<Backdrop>
with SingleTickerProviderStateMixin {
final _backdropKey = GlobalKey(debugLabel: 'Backdrop');
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 300),
// value of 0 hides the panel; value of 1 fully shows the panel
value: (widget.panelVisible?.value ?? true) ? 1.0 : 0.0,
vsync: this,
);
// Listen on the toggle value notifier if it's not null
widget.panelVisible?.addListener(_subscribeToValueNotifier);
// Ensure that the value notifier is updated when the panel is opened or closed
if (widget.panelVisible != null) {
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed)
widget.panelVisible.value = true;
else if (status == AnimationStatus.dismissed)
widget.panelVisible.value = false;
});
}
}
void _subscribeToValueNotifier() {
if (widget.panelVisible.value != _backdropPanelVisible)
_toggleBackdropPanelVisibility();
}
/// Required for resubscribing when hot reload occurs
@override
void didUpdateWidget(Backdrop oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.panelVisible?.removeListener(_subscribeToValueNotifier);
widget.panelVisible?.addListener(_subscribeToValueNotifier);
}
@override
void dispose() {
_controller.dispose();
widget.panelVisible?.dispose();
super.dispose();
}
bool get _backdropPanelVisible =>
_controller.status == AnimationStatus.completed ||
_controller.status == AnimationStatus.forward;
void _toggleBackdropPanelVisibility() => _controller.fling(
velocity: _backdropPanelVisible ? -_kFlingVelocity : _kFlingVelocity);
double get _backdropHeight {
final RenderBox renderBox = _backdropKey.currentContext.findRenderObject();
return renderBox.size.height;
}
void _handleDragUpdate(DragUpdateDetails details) {
if (!_controller.isAnimating)
_controller.value -= details.primaryDelta / _backdropHeight;
}
void _handleDragEnd(DragEndDetails details) {
if (_controller.isAnimating ||
_controller.status == AnimationStatus.completed) return;
final double flingVelocity =
details.velocity.pixelsPerSecond.dy / _backdropHeight;
if (flingVelocity < 0.0)
_controller.fling(velocity: math.max(_kFlingVelocity, -flingVelocity));
else if (flingVelocity > 0.0)
_controller.fling(velocity: math.min(-_kFlingVelocity, -flingVelocity));
else
_controller.fling(
velocity:
_controller.value < 0.5 ? -_kFlingVelocity : _kFlingVelocity);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final panelSize = constraints.biggest;
final closedPercentage = widget.frontHeaderVisibleClosed
? (panelSize.height - widget.frontHeaderHeight) / panelSize.height
: 1.0;
final openPercentage = widget.frontPanelOpenHeight / panelSize.height;
final panelDetailsPosition = Tween<Offset>(
begin: Offset(0.0, closedPercentage),
end: Offset(0.0, openPercentage),
).animate(_controller.view);
return Container(
key: _backdropKey,
child: Stack(
children: <Widget>[
widget.backLayer,
SlideTransition(
position: panelDetailsPosition,
child: _BackdropPanel(
onTap: _toggleBackdropPanelVisibility,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
title: widget.frontHeader,
titleHeight: widget.frontHeaderHeight,
child: widget.frontLayer,
padding: widget.frontPanelPadding,
),
),
],
),
);
});
}
}
我强烈推荐这个套餐miniplayer 0.6.1 它基本上包含您需要的一切,还提供可定制的迷你和完整播放器。但是你将不得不阅读本机代码并进行一些更改。
除此之外,动画部分也可以使用Flutter Heroes。它也适用于这种情况。
您可以使用 DraggableScrollableSheet widget. Also this article 将帮助您开始使用小部件。