mirror of
https://github.com/flutter/samples.git
synced 2026-04-06 03:31:03 +00:00
Desktop photo search (#174)
This commit is contained in:
350
experimental/desktop_photo_search/lib/src/widgets/data_tree.dart
Normal file
350
experimental/desktop_photo_search/lib/src/widgets/data_tree.dart
Normal file
@@ -0,0 +1,350 @@
|
||||
// Copyright 2019 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/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class DataTreeRule extends StatelessWidget {
|
||||
const DataTreeRule({Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 2,
|
||||
color: Theme.of(context).colorScheme.onBackground.withOpacity(0.1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DataTreeInkWell extends StatefulWidget {
|
||||
const DataTreeInkWell({Key key, this.onTap, this.isSelected, this.child})
|
||||
: super(key: key);
|
||||
|
||||
final VoidCallback onTap;
|
||||
final bool isSelected;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
_DataTreeInkWellState createState() => _DataTreeInkWellState();
|
||||
}
|
||||
|
||||
class _DataTreeInkWellState extends State<DataTreeInkWell>
|
||||
with SingleTickerProviderStateMixin {
|
||||
AnimationController controller;
|
||||
Animation selectionColor;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 100), vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DataTreeInkWell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isSelected) {
|
||||
controller.forward();
|
||||
} else {
|
||||
controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final Animation<Color> backgroundColor = controller
|
||||
.drive(CurveTween(curve: Curves.fastOutSlowIn))
|
||||
.drive(ColorTween(
|
||||
begin: colorScheme.primary.withOpacity(0.0),
|
||||
end: colorScheme.primary.withOpacity(0.08),
|
||||
));
|
||||
|
||||
final Animation<Color> iconColor = controller
|
||||
.drive(CurveTween(curve: Curves.fastOutSlowIn))
|
||||
.drive(ColorTween(
|
||||
begin: colorScheme.onBackground.withOpacity(0.54),
|
||||
end: colorScheme.onBackground.withOpacity(0.87),
|
||||
));
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
return IconTheme(
|
||||
data: IconThemeData(color: iconColor.value),
|
||||
child: Container(
|
||||
color: backgroundColor.value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
splashFactory: InkRipple.splashFactory,
|
||||
splashColor: colorScheme.primary.withOpacity(0.14),
|
||||
highlightColor: Colors.transparent,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DataTreeNode extends StatefulWidget {
|
||||
const DataTreeNode({
|
||||
Key key,
|
||||
this.leading,
|
||||
@required this.title,
|
||||
this.backgroundColor,
|
||||
this.onExpansionChanged,
|
||||
this.onSelectionChanged,
|
||||
this.children = const <Widget>[],
|
||||
this.initiallyExpanded = false,
|
||||
this.indent = 0,
|
||||
this.height = 36,
|
||||
}) : assert(initiallyExpanded != null),
|
||||
assert(indent != null && indent >= 0),
|
||||
super(key: key);
|
||||
|
||||
final Widget leading;
|
||||
final Widget title;
|
||||
final ValueChanged<bool> onExpansionChanged;
|
||||
final ValueChanged<bool> onSelectionChanged;
|
||||
final List<Widget> children;
|
||||
final Color backgroundColor;
|
||||
final bool initiallyExpanded;
|
||||
final double indent;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
_DataTreeNodeState createState() => _DataTreeNodeState();
|
||||
}
|
||||
|
||||
class _DataTreeNodeState extends State<DataTreeNode>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static final Animatable<double> _easeInTween =
|
||||
CurveTween(curve: Curves.easeIn);
|
||||
static final Animatable<double> _halfTween =
|
||||
Tween<double>(begin: 0.0, end: 0.25);
|
||||
|
||||
AnimationController _controller;
|
||||
Animation<double> _iconTurns;
|
||||
Animation<double> _heightFactor;
|
||||
|
||||
bool _isExpanded = false;
|
||||
bool _isSelected = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 200), vsync: this);
|
||||
_heightFactor = _controller.drive(_easeInTween);
|
||||
_iconTurns = _controller.drive(_halfTween.chain(_easeInTween));
|
||||
_isExpanded = (PageStorage.of(context)?.readState(context) ??
|
||||
widget.initiallyExpanded) as bool;
|
||||
if (_isExpanded) {
|
||||
_controller.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleNodeTap() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
if (_isExpanded) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse().then<void>((value) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
// Rebuild without widget.children.
|
||||
});
|
||||
});
|
||||
}
|
||||
PageStorage.of(context)?.writeState(context, _isExpanded);
|
||||
});
|
||||
if (widget.onExpansionChanged != null) {
|
||||
widget.onExpansionChanged(_isExpanded);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLeafTap() {
|
||||
_isSelected = !_isSelected;
|
||||
if (widget.onSelectionChanged != null) {
|
||||
widget.onSelectionChanged(_isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChildren(BuildContext context, Widget child) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
DataTreeInkWell(
|
||||
onTap: _handleNodeTap,
|
||||
isSelected: _isExpanded,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(width: widget.indent),
|
||||
RotationTransition(
|
||||
turns: _iconTurns,
|
||||
child: Icon(
|
||||
Icons.arrow_right,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.folder,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
widget.title,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (child != null) // If child == null, then this DataNode is closed.
|
||||
const DataTreeRule(),
|
||||
if (child != null)
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
heightFactor: _heightFactor.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
final Color textColor = colorScheme.onBackground.withOpacity(0.87);
|
||||
|
||||
final bool closed = !_isExpanded && _controller.isDismissed;
|
||||
|
||||
return widget.children.isEmpty
|
||||
// Leaf node.
|
||||
? DataTreeInkWell(
|
||||
onTap: _handleLeafTap,
|
||||
isSelected: _isSelected,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
SizedBox(width: widget.indent),
|
||||
Icon(
|
||||
Icons.web_asset,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.body1.copyWith(
|
||||
color: textColor,
|
||||
),
|
||||
child: widget.title,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
// Not a leaf node.
|
||||
: AnimatedBuilder(
|
||||
animation: _controller.view,
|
||||
builder: _buildChildren,
|
||||
child: closed
|
||||
? null
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
for (int index = 0;
|
||||
index < (widget.children.length * 2) - 1;
|
||||
index += 1)
|
||||
(index % 2 == 1)
|
||||
? const DataTreeRule()
|
||||
: widget.children[index ~/ 2],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// One entry in the multilevel list displayed by this app.
|
||||
class Entry {
|
||||
Entry(this.title, [this.children = const <Entry>[]]);
|
||||
final String title;
|
||||
final List<Entry> children;
|
||||
bool isSelected = false;
|
||||
bool isEnabled = true;
|
||||
}
|
||||
|
||||
// A visualization of one Entry based on DataTreeNode.
|
||||
class EntryItem extends StatefulWidget {
|
||||
const EntryItem({Key key, this.entry}) : super(key: key);
|
||||
|
||||
final Entry entry;
|
||||
|
||||
@override
|
||||
_EntryItemState createState() => _EntryItemState();
|
||||
}
|
||||
|
||||
class _EntryItemState extends State<EntryItem> {
|
||||
Widget _buildNodes(Entry root, double indent) {
|
||||
return DataTreeNode(
|
||||
key: PageStorageKey<Entry>(root),
|
||||
onSelectionChanged: (isSelected) {
|
||||
setState(() {
|
||||
root.isSelected = isSelected;
|
||||
});
|
||||
},
|
||||
title: Container(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
height: 36,
|
||||
child: Text(root.title),
|
||||
),
|
||||
indent: indent,
|
||||
children: root.children.map<Widget>((entry) {
|
||||
return _buildNodes(entry, indent + 28);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _buildNodes(widget.entry, 16);
|
||||
}
|
||||
}
|
||||
|
||||
class DataTree extends StatelessWidget {
|
||||
DataTree(this.entries)
|
||||
: assert(entries != null),
|
||||
assert(entries.isNotEmpty);
|
||||
final List<Entry> entries;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView.builder(
|
||||
itemCount: entries.length * 2 - 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index % 2 == 1) {
|
||||
return const DataTreeRule();
|
||||
}
|
||||
return EntryItem(
|
||||
entry: entries[index ~/ 2],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2019 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/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:transparent_image/transparent_image.dart';
|
||||
import 'package:url_launcher/url_launcher.dart' as url_launcher;
|
||||
|
||||
import '../../unsplash_access_key.dart';
|
||||
import '../unsplash/photo.dart';
|
||||
|
||||
final _unsplashHomepage = Uri.encodeFull(
|
||||
'https://unsplash.com/?utm_source=$unsplashAppName&utm_medium=referral');
|
||||
|
||||
typedef PhotoDetailsPhotoSaveCallback = void Function(Photo);
|
||||
|
||||
class PhotoDetails extends StatefulWidget {
|
||||
const PhotoDetails({
|
||||
@required this.photo,
|
||||
@required this.onPhotoSave,
|
||||
});
|
||||
final Photo photo;
|
||||
final PhotoDetailsPhotoSaveCallback onPhotoSave;
|
||||
|
||||
@override
|
||||
_PhotoDetailsState createState() => _PhotoDetailsState();
|
||||
}
|
||||
|
||||
class _PhotoDetailsState extends State<PhotoDetails>
|
||||
with SingleTickerProviderStateMixin {
|
||||
Widget _buildPhotoAttribution(BuildContext context) {
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.body1,
|
||||
children: [
|
||||
const TextSpan(text: 'Photo by '),
|
||||
TextSpan(
|
||||
text: widget.photo.user.name,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
final url = Uri.encodeFull(
|
||||
'https://unsplash.com/@${widget.photo.user.username}?utm_source=$unsplashAppName&utm_medium=referral');
|
||||
if (await url_launcher.canLaunch(url)) {
|
||||
await url_launcher.launch(url);
|
||||
}
|
||||
},
|
||||
),
|
||||
const TextSpan(text: ' on '),
|
||||
TextSpan(
|
||||
text: 'Unsplash',
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
if (await url_launcher.canLaunch(_unsplashHomepage)) {
|
||||
await url_launcher.launch(_unsplashHomepage);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
shape: ContinuousRectangleBorder(
|
||||
side: BorderSide(color: Colors.black12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: AnimatedSize(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: 750),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: 400,
|
||||
minHeight: 400,
|
||||
),
|
||||
child: FadeInImage.memoryNetwork(
|
||||
placeholder: kTransparentImage,
|
||||
image: widget.photo.urls.small,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildPhotoAttribution(context),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(Icons.cloud_download),
|
||||
onPressed: () => widget.onPhotoSave(widget.photo),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2019 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/material.dart';
|
||||
|
||||
typedef PhotoSearchDialogCallback = void Function(String searchQuery);
|
||||
|
||||
class PhotoSearchDialog extends StatefulWidget {
|
||||
const PhotoSearchDialog(this.callback);
|
||||
final PhotoSearchDialogCallback callback;
|
||||
@override
|
||||
State<PhotoSearchDialog> createState() => _PhotoSearchDialogState();
|
||||
}
|
||||
|
||||
class _PhotoSearchDialogState extends State<PhotoSearchDialog> {
|
||||
final _controller = TextEditingController();
|
||||
bool _searchEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() {
|
||||
setState(() {
|
||||
_searchEnabled = _controller.text.isNotEmpty;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('Photo Search'),
|
||||
content: TextField(
|
||||
autofocus: true,
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search query',
|
||||
),
|
||||
onSubmitted: (content) {
|
||||
if (content.isNotEmpty) {
|
||||
widget.callback(content);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
onPressed: _searchEnabled
|
||||
? () {
|
||||
widget.callback(_controller.text);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
: null,
|
||||
child: Text('Search'.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
194
experimental/desktop_photo_search/lib/src/widgets/split.dart
Normal file
194
experimental/desktop_photo_search/lib/src/widgets/split.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright 2019 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that takes two children, lays them out along [axis], and allows
|
||||
/// the user to resize them.
|
||||
///
|
||||
/// The user can customize the amount of space allocated to each child by
|
||||
/// dragging a divider between them.
|
||||
///
|
||||
/// [initialFirstFraction] defines how much space to give the [firstChild]
|
||||
/// when first building this widget. [secondChild] will take the remaining
|
||||
/// space.
|
||||
///
|
||||
/// The user can drag the widget with key [dividerKey] to change
|
||||
/// the space allocated between [firstChild] and [secondChild].
|
||||
// TODO(djshuckerow): introduce support for a minimum fraction a child
|
||||
// is allowed.
|
||||
class Split extends StatefulWidget {
|
||||
/// Builds a split oriented along [axis].
|
||||
const Split({
|
||||
Key key,
|
||||
@required this.axis,
|
||||
@required this.firstChild,
|
||||
@required this.secondChild,
|
||||
double initialFirstFraction,
|
||||
}) : initialFirstFraction = initialFirstFraction ?? 0.5,
|
||||
assert(axis != null),
|
||||
assert(firstChild != null),
|
||||
assert(secondChild != null),
|
||||
super(key: key);
|
||||
|
||||
/// The main axis the children will lay out on.
|
||||
///
|
||||
/// If [Axis.horizontal], the children will be placed in a [Row]
|
||||
/// and they will be horizontally resizable.
|
||||
///
|
||||
/// If [Axis.vertical], the children will be placed in a [Column]
|
||||
/// and they will be vertically resizable.
|
||||
///
|
||||
/// Cannot be null.
|
||||
final Axis axis;
|
||||
|
||||
/// The child that will be laid out first along [axis].
|
||||
final Widget firstChild;
|
||||
|
||||
/// The child that will be laid out last along [axis].
|
||||
final Widget secondChild;
|
||||
|
||||
/// The fraction of the layout to allocate to [firstChild].
|
||||
///
|
||||
/// [secondChild] will receive a fraction of `1 - initialFirstFraction`.
|
||||
final double initialFirstFraction;
|
||||
|
||||
/// The key passed to the divider between [firstChild] and [secondChild].
|
||||
///
|
||||
/// Visible to grab it in tests.
|
||||
@visibleForTesting
|
||||
Key get dividerKey => Key('$this dividerKey');
|
||||
|
||||
/// The size of the divider between [firstChild] and [secondChild] in
|
||||
/// logical pixels (dp, not px).
|
||||
static const double dividerMainAxisSize = 10;
|
||||
|
||||
static Axis axisFor(BuildContext context, double horizontalAspectRatio) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final aspectRatio = screenSize.width / screenSize.height;
|
||||
if (aspectRatio >= horizontalAspectRatio) {
|
||||
return Axis.horizontal;
|
||||
}
|
||||
return Axis.vertical;
|
||||
}
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _SplitState();
|
||||
}
|
||||
|
||||
class _SplitState extends State<Split> {
|
||||
double firstFraction;
|
||||
|
||||
double get secondFraction => 1 - firstFraction;
|
||||
|
||||
bool get isHorizontal => widget.axis == Axis.horizontal;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
firstFraction = widget.initialFirstFraction;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: _buildLayout);
|
||||
}
|
||||
|
||||
Widget _buildLayout(BuildContext context, BoxConstraints constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = constraints.maxHeight;
|
||||
final axisSize = isHorizontal ? width : height;
|
||||
final crossAxisSize = isHorizontal ? height : width;
|
||||
const halfDivider = Split.dividerMainAxisSize / 2.0;
|
||||
|
||||
// Determine what fraction to give each child, including enough space to
|
||||
// display the divider.
|
||||
double firstSize = axisSize * firstFraction;
|
||||
double secondSize = axisSize * secondFraction;
|
||||
|
||||
// Clamp the sizes to be sure there is enough space for the dividers.
|
||||
firstSize = firstSize.clamp(halfDivider, axisSize - halfDivider) as double;
|
||||
secondSize =
|
||||
secondSize.clamp(halfDivider, axisSize - halfDivider) as double;
|
||||
|
||||
// Remove space from each child to place the divider in the middle.
|
||||
firstSize = firstSize - halfDivider;
|
||||
secondSize = secondSize - halfDivider;
|
||||
|
||||
void updateSpacing(DragUpdateDetails dragDetails) {
|
||||
final delta = isHorizontal ? dragDetails.delta.dx : dragDetails.delta.dy;
|
||||
final fractionalDelta = delta / axisSize;
|
||||
setState(() {
|
||||
// Update the fraction of space consumed by the children,
|
||||
// being sure not to allocate any negative space.
|
||||
firstFraction += fractionalDelta;
|
||||
firstFraction = firstFraction.clamp(0.0, 1.0) as double;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO(https://github.com/flutter/flutter/issues/43747): use an icon.
|
||||
// The material icon for a drag handle is not currently available.
|
||||
// For now, draw an indicator that is 3 lines running in the direction
|
||||
// of the main axis, like a hamburger menu.
|
||||
// TODO(https://github.com/flutter/devtools/issues/1265): update mouse
|
||||
// to indicate that this is resizable.
|
||||
final dragIndicator = Flex(
|
||||
direction: isHorizontal ? Axis.vertical : Axis.horizontal,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < min(crossAxisSize / 6.0, 3).floor(); i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: isHorizontal ? 2.0 : 0.0,
|
||||
horizontal: isHorizontal ? 0.0 : 2.0,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(Split.dividerMainAxisSize),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: isHorizontal ? 2.0 : Split.dividerMainAxisSize - 2.0,
|
||||
width: isHorizontal ? Split.dividerMainAxisSize - 2.0 : 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final children = [
|
||||
SizedBox(
|
||||
width: isHorizontal ? firstSize : width,
|
||||
height: isHorizontal ? height : firstSize,
|
||||
child: widget.firstChild,
|
||||
),
|
||||
GestureDetector(
|
||||
key: widget.dividerKey,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragUpdate: isHorizontal ? updateSpacing : null,
|
||||
onVerticalDragUpdate: isHorizontal ? null : updateSpacing,
|
||||
// DartStartBehavior.down is needed to keep the mouse pointer stuck to
|
||||
// the drag bar. There still appears to be a few frame lag before the
|
||||
// drag action triggers which is't ideal but isn't a launch blocker.
|
||||
dragStartBehavior: DragStartBehavior.down,
|
||||
child: SizedBox(
|
||||
width: isHorizontal ? Split.dividerMainAxisSize : width,
|
||||
height: isHorizontal ? height : Split.dividerMainAxisSize,
|
||||
child: Center(
|
||||
child: dragIndicator,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isHorizontal ? secondSize : width,
|
||||
height: isHorizontal ? height : secondSize,
|
||||
child: widget.secondChild,
|
||||
),
|
||||
];
|
||||
return Flex(direction: widget.axis, children: children);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user