blob: a063207640e9eae1ddc779760ec581c69ffbdb33 [file] [log] [blame]
// 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' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'primitives/utils.dart';
/// A widget that takes a list of 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.
///
/// [initialFractions] defines how much space to give each child when building
/// this widget.
class Split extends StatefulWidget {
/// Builds a split oriented along [axis].
Split({
Key? key,
required this.axis,
required this.children,
required this.initialFractions,
this.minSizes,
this.splitters,
}) : assert(children.length >= 2),
assert(initialFractions.length >= 2),
assert(children.length == initialFractions.length),
super(key: key) {
_verifyFractionsSumTo1(initialFractions);
if (minSizes != null) {
assert(minSizes!.length == children.length);
}
if (splitters != null) {
assert(splitters!.length == children.length - 1);
}
}
/// 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 children that will be laid out along [axis].
final List<Widget> children;
/// The fraction of the layout to allocate to each child in [children].
///
/// The index of [initialFractions] corresponds to the child at index of
/// [children].
final List<double> initialFractions;
/// The minimum size each child is allowed to be.
final List<double>? minSizes;
/// Splitter widgets to divide [children].
///
/// If this is null, a default splitter will be used to divide [children].
final List<PreferredSizeWidget>? splitters;
/// The key passed to the divider between children[index] and
/// children[index + 1].
///
/// Visible to grab it in tests.
@visibleForTesting
Key dividerKey(int index) => Key('$this dividerKey $index');
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> {
late final List<double> fractions;
bool get isHorizontal => widget.axis == Axis.horizontal;
@override
void initState() {
super.initState();
fractions = List.of(widget.initialFractions);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: _buildLayout);
}
Widget _buildLayout(BuildContext _, BoxConstraints constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
final axisSize = isHorizontal ? width : height;
final availableSize = axisSize - _totalSplitterSize();
// Size calculation helpers.
double minSizeForIndex(int index) {
if (widget.minSizes == null) return 0.0;
double totalMinSize = 0;
for (var minSize in widget.minSizes!) {
totalMinSize += minSize;
}
// Reduce the min sizes gracefully if the total required min size for all
// children is greater than the available size for children.
return totalMinSize > availableSize
? widget.minSizes![index] * availableSize / totalMinSize
: widget.minSizes![index];
}
double minFractionForIndex(int index) =>
minSizeForIndex(index) / availableSize;
void clampFraction(int index) {
fractions[index] =
fractions[index].clamp(minFractionForIndex(index), 1.0);
}
double sizeForIndex(int index) => availableSize * fractions[index];
double fractionDeltaRequired = 0.0;
double fractionDeltaAvailable = 0.0;
double deltaFromMinimumSize(int index) =>
fractions[index] - minFractionForIndex(index);
for (int i = 0; i < fractions.length; ++i) {
final delta = deltaFromMinimumSize(i);
if (delta < 0) {
fractionDeltaRequired -= delta;
} else {
fractionDeltaAvailable += delta;
}
}
if (fractionDeltaRequired > 0) {
// Likely due to a change in the available size, the current fractions for
// the children do not obey the min size constraints.
// The min size constraints for children are scaled so it is always
// possible to meet them. A scaleFactor greater than 1 would indicate that
// it is impossible to meet the constraints.
double scaleFactor = fractionDeltaRequired / fractionDeltaAvailable;
assert(scaleFactor <= 1 + defaultEpsilon);
scaleFactor = math.min(scaleFactor, 1.0);
for (int i = 0; i < fractions.length; ++i) {
final delta = deltaFromMinimumSize(i);
if (delta < 0) {
// This is equivalent to adding delta but avoids rounding error.
fractions[i] = minFractionForIndex(i);
} else {
// Reduce all fractions that are above their minimum size by an amount
// proportional to their ability to reduce their size without
// violating their minimum size constraints.
fractions[i] -= delta * scaleFactor;
}
}
}
// Determine what fraction to give each child, including enough space to
// display the divider.
final sizes = List.generate(fractions.length, (i) => sizeForIndex(i));
void updateSpacing(DragUpdateDetails dragDetails, int splitterIndex) {
final dragDelta =
isHorizontal ? dragDetails.delta.dx : dragDetails.delta.dy;
final fractionalDelta = dragDelta / axisSize;
// Returns the actual delta applied to elements before the splitter.
double updateSpacingBeforeSplitterIndex(double delta) {
final startingDelta = delta;
var index = splitterIndex;
while (index >= 0) {
fractions[index] += delta;
final minFraction = minFractionForIndex(index);
if (fractions[index] >= minFraction) {
clampFraction(index);
return startingDelta;
}
delta = fractions[index] - minFraction;
clampFraction(index);
index--;
}
// At this point, we know that both [startingDelta] and [delta] are
// negative, and that [delta] represents the overflow that did not get
// applied.
return startingDelta - delta;
}
// Returns the actual delta applied to elements after the splitter.
double updateSpacingAfterSplitterIndex(double delta) {
final startingDelta = delta;
var index = splitterIndex + 1;
while (index < fractions.length) {
fractions[index] += delta;
final minFraction = minFractionForIndex(index);
if (fractions[index] >= minFraction) {
clampFraction(index);
return startingDelta;
}
delta = fractions[index] - minFraction;
clampFraction(index);
index++;
}
// At this point, we know that both [startingDelta] and [delta] are
// negative, and that [delta] represents the overflow that did not get
// applied.
return startingDelta - delta;
}
setState(() {
// Update the fraction of space consumed by the children. Always update
// the shrinking children first so that we do not over-increase the size
// of the growing children and cause layout overflow errors.
if (fractionalDelta <= 0.0) {
final appliedDelta =
updateSpacingBeforeSplitterIndex(fractionalDelta);
updateSpacingAfterSplitterIndex(-appliedDelta);
} else {
final appliedDelta =
updateSpacingAfterSplitterIndex(-fractionalDelta);
updateSpacingBeforeSplitterIndex(-appliedDelta);
}
});
_verifyFractionsSumTo1(fractions);
}
final children = <Widget>[];
for (int i = 0; i < widget.children.length; i++) {
children.addAll([
SizedBox(
width: isHorizontal ? sizes[i] : width,
height: isHorizontal ? height : sizes[i],
child: widget.children[i],
),
if (i < widget.children.length - 1)
MouseRegion(
cursor: isHorizontal
? SystemMouseCursors.resizeColumn
: SystemMouseCursors.resizeRow,
child: GestureDetector(
key: widget.dividerKey(i),
behavior: HitTestBehavior.translucent,
onHorizontalDragUpdate: (details) =>
isHorizontal ? updateSpacing(details, i) : null,
onVerticalDragUpdate: (details) =>
isHorizontal ? null : updateSpacing(details, i),
// 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: widget.splitters != null
? widget.splitters![i]
: DefaultSplitter(isHorizontal: isHorizontal),
),
),
]);
}
return Flex(
direction: widget.axis,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
}
double _totalSplitterSize() {
final numSplitters = widget.children.length - 1;
if (widget.splitters == null) {
return numSplitters * DefaultSplitter.splitterWidth;
} else {
var totalSize = 0.0;
for (var splitter in widget.splitters!) {
totalSize += isHorizontal
? splitter.preferredSize.width
: splitter.preferredSize.height;
}
return totalSize;
}
}
}
class DefaultSplitter extends StatelessWidget {
const DefaultSplitter({super.key, required this.isHorizontal});
static const double iconSize = 24.0;
static const double splitterWidth = 12.0;
final bool isHorizontal;
@override
Widget build(BuildContext context) {
return Transform.rotate(
angle: isHorizontal ? degToRad(90.0) : degToRad(0.0),
child: Align(
widthFactor: 0.5,
heightFactor: 0.5,
child: Icon(
Icons.drag_handle,
size: iconSize,
color: Theme.of(context).focusColor,
),
),
);
}
}
void _verifyFractionsSumTo1(List<double> fractions) {
var sumFractions = 0.0;
for (var fraction in fractions) {
sumFractions += fraction;
}
assert(
(1.0 - sumFractions).abs() < defaultEpsilon,
'Fractions should sum to 1.0, but instead sum to $sumFractions:\n$fractions',
);
}