1202 lines
34 KiB
Dart
1202 lines
34 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:html_editor_enhanced/html_editor.dart';
|
|
import 'package:html_editor_enhanced/utils/shims/dart_ui.dart';
|
|
|
|
/// small function to always check if mounted before running setState()
|
|
void setState(
|
|
bool mounted, void Function(Function()) setState, void Function() fn) {
|
|
if (mounted) {
|
|
setState.call(fn);
|
|
}
|
|
}
|
|
|
|
/// courtesy of @modulovalue (https://github.com/modulovalue/dart_intersperse/blob/master/lib/src/intersperse.dart)
|
|
/// intersperses elements in between list items - used to insert dividers between
|
|
/// toolbar buttons when using [ToolbarType.nativeScrollable]
|
|
Iterable<T> intersperse<T>(T element, Iterable<T> iterable) sync* {
|
|
final iterator = iterable.iterator;
|
|
if (iterator.moveNext()) {
|
|
yield iterator.current;
|
|
while (iterator.moveNext()) {
|
|
yield element;
|
|
yield iterator.current;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generates a random string to be used as the [VisibilityDetector] key.
|
|
/// Technically this limits the number of editors to a finite number, but
|
|
/// nobody will be embedding enough editors to reach the theoretical limit
|
|
/// (yes, this is a challenge ;-) )
|
|
String getRandString(int len) {
|
|
var random = Random.secure();
|
|
var values = List<int>.generate(len, (i) => random.nextInt(255));
|
|
return base64UrlEncode(values);
|
|
}
|
|
|
|
/// Class that helps pass editor settings to the [onSettingsChange] callback
|
|
class EditorSettings {
|
|
String parentElement;
|
|
String fontName;
|
|
double fontSize;
|
|
bool isBold;
|
|
bool isItalic;
|
|
bool isUnderline;
|
|
bool isStrikethrough;
|
|
bool isSuperscript;
|
|
bool isSubscript;
|
|
Color foregroundColor;
|
|
Color backgroundColor;
|
|
bool isUl;
|
|
bool isOl;
|
|
bool isAlignLeft;
|
|
bool isAlignCenter;
|
|
bool isAlignRight;
|
|
bool isAlignJustify;
|
|
double lineHeight;
|
|
TextDirection textDirection;
|
|
|
|
EditorSettings({
|
|
required this.parentElement,
|
|
required this.fontName,
|
|
required this.fontSize,
|
|
required this.isBold,
|
|
required this.isItalic,
|
|
required this.isUnderline,
|
|
required this.isStrikethrough,
|
|
required this.isSuperscript,
|
|
required this.isSubscript,
|
|
required this.foregroundColor,
|
|
required this.backgroundColor,
|
|
required this.isUl,
|
|
required this.isOl,
|
|
required this.isAlignLeft,
|
|
required this.isAlignCenter,
|
|
required this.isAlignRight,
|
|
required this.isAlignJustify,
|
|
required this.lineHeight,
|
|
required this.textDirection,
|
|
});
|
|
}
|
|
|
|
/// Class to create a script that can be run on Flutter Web.
|
|
///
|
|
/// [name] provides a unique identifier for the script. Note: It must be unique!
|
|
/// Otherwise your script may not be called when using [controller.evaluateJavascriptWeb].
|
|
/// [script] provides the script itself. If you'd like to return a value back to
|
|
/// Dart, you can do that via a postMessage call (see the README for an example).
|
|
class WebScript {
|
|
String name;
|
|
String script;
|
|
|
|
WebScript({
|
|
required this.name,
|
|
required this.script,
|
|
}) : assert(name.isNotEmpty && script.isNotEmpty);
|
|
}
|
|
|
|
/// Delegate for the icon that controls the expansion status of the toolbar
|
|
class ExpandIconDelegate extends SliverPersistentHeaderDelegate {
|
|
final double? _size;
|
|
final bool _isExpanded;
|
|
final void Function() _setState;
|
|
|
|
ExpandIconDelegate(this._size, this._isExpanded, this._setState);
|
|
|
|
@override
|
|
Widget build(
|
|
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
|
return Container(
|
|
height: _size,
|
|
width: _size,
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
child: IconButton(
|
|
constraints: BoxConstraints(
|
|
maxHeight: _size!,
|
|
maxWidth: _size!,
|
|
),
|
|
iconSize: _size! * 3 / 5,
|
|
icon: Icon(
|
|
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
|
color: Colors.grey,
|
|
),
|
|
onPressed: () async {
|
|
_setState.call();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
double get maxExtent => _size!;
|
|
|
|
@override
|
|
double get minExtent => _size!;
|
|
|
|
@override
|
|
bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// The following code contains all the code necessary for custom dropdowns.
|
|
/// It is really long because dropdowns utilize a bunch of private classes that
|
|
/// must be copy pasted.
|
|
/// The main change is marked with a comment in the code (CTRL-F "main change")
|
|
|
|
const Duration _kDropdownMenuDuration = Duration(milliseconds: 300);
|
|
const double _kMenuItemHeight = kMinInteractiveDimension;
|
|
const double _kDenseButtonHeight = 24.0;
|
|
const EdgeInsets _kMenuItemPadding = EdgeInsets.symmetric(horizontal: 16.0);
|
|
const EdgeInsetsGeometry _kAlignedButtonPadding =
|
|
EdgeInsetsDirectional.only(start: 16.0, end: 4.0);
|
|
const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero;
|
|
const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero;
|
|
const EdgeInsetsGeometry _kUnalignedMenuMargin =
|
|
EdgeInsetsDirectional.only(start: 16.0, end: 24.0);
|
|
|
|
typedef DropdownButtonBuilder = List<Widget> Function(BuildContext context);
|
|
|
|
class _DropdownMenuPainter extends CustomPainter {
|
|
_DropdownMenuPainter({
|
|
this.color,
|
|
this.elevation,
|
|
this.selectedIndex,
|
|
required this.resize,
|
|
required this.getSelectedItemOffset,
|
|
}) : _painter = BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(2.0),
|
|
boxShadow: kElevationToShadow[elevation],
|
|
).createBoxPainter(),
|
|
super(repaint: resize);
|
|
|
|
final Color? color;
|
|
final int? elevation;
|
|
final int? selectedIndex;
|
|
final Animation<double> resize;
|
|
final ValueGetter<double> getSelectedItemOffset;
|
|
final BoxPainter _painter;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final selectedItemOffset = getSelectedItemOffset();
|
|
final top = Tween<double>(
|
|
begin: selectedItemOffset.clamp(
|
|
0.0, max(size.height - _kMenuItemHeight, 0.0)),
|
|
end: 0.0,
|
|
);
|
|
|
|
final bottom = Tween<double>(
|
|
begin: (top.begin! + _kMenuItemHeight)
|
|
.clamp(min(_kMenuItemHeight, size.height), size.height),
|
|
end: size.height,
|
|
);
|
|
|
|
final rect = Rect.fromLTRB(
|
|
0.0, top.evaluate(resize), size.width, bottom.evaluate(resize));
|
|
|
|
_painter.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size));
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_DropdownMenuPainter oldPainter) {
|
|
return oldPainter.color != color ||
|
|
oldPainter.elevation != elevation ||
|
|
oldPainter.selectedIndex != selectedIndex ||
|
|
oldPainter.resize != resize;
|
|
}
|
|
}
|
|
|
|
class _DropdownMenuItemButton<T> extends StatefulWidget {
|
|
const _DropdownMenuItemButton({
|
|
Key? key,
|
|
this.padding,
|
|
required this.route,
|
|
required this.buttonRect,
|
|
required this.constraints,
|
|
required this.itemIndex,
|
|
}) : super(key: key);
|
|
|
|
final _DropdownRoute<T> route;
|
|
final EdgeInsets? padding;
|
|
final Rect buttonRect;
|
|
final BoxConstraints constraints;
|
|
final int itemIndex;
|
|
|
|
@override
|
|
_DropdownMenuItemButtonState<T> createState() =>
|
|
_DropdownMenuItemButtonState<T>();
|
|
}
|
|
|
|
class _DropdownMenuItemButtonState<T>
|
|
extends State<_DropdownMenuItemButton<T>> {
|
|
void _handleFocusChange(bool focused) {
|
|
final bool inTraditionalMode;
|
|
switch (FocusManager.instance.highlightMode) {
|
|
case FocusHighlightMode.touch:
|
|
inTraditionalMode = false;
|
|
break;
|
|
case FocusHighlightMode.traditional:
|
|
inTraditionalMode = true;
|
|
break;
|
|
}
|
|
|
|
if (focused && inTraditionalMode) {
|
|
final menuLimits = widget.route.getMenuLimits(
|
|
widget.buttonRect,
|
|
widget.constraints.maxHeight,
|
|
widget.itemIndex,
|
|
);
|
|
widget.route.scrollController!.animateTo(
|
|
menuLimits.scrollOffset,
|
|
curve: Curves.easeInOut,
|
|
duration: const Duration(milliseconds: 100),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleOnTap() {
|
|
final dropdownMenuItem = widget.route.items[widget.itemIndex].item!;
|
|
|
|
dropdownMenuItem.onTap?.call();
|
|
|
|
Navigator.pop(
|
|
context,
|
|
_DropdownRouteResult<T>(dropdownMenuItem.value),
|
|
);
|
|
}
|
|
|
|
static final Map<LogicalKeySet, Intent> _webShortcuts =
|
|
<LogicalKeySet, Intent>{
|
|
LogicalKeySet(LogicalKeyboardKey.arrowDown):
|
|
const DirectionalFocusIntent(TraversalDirection.down),
|
|
LogicalKeySet(LogicalKeyboardKey.arrowUp):
|
|
const DirectionalFocusIntent(TraversalDirection.up),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final CurvedAnimation opacity;
|
|
final unit = 0.5 / (widget.route.items.length + 1.5);
|
|
if (widget.itemIndex == widget.route.selectedIndex) {
|
|
opacity = CurvedAnimation(
|
|
parent: widget.route.animation!, curve: const Threshold(0.0));
|
|
} else {
|
|
final start = (0.5 + (widget.itemIndex + 1) * unit).clamp(0.0, 1.0);
|
|
final end = (start + 1.5 * unit).clamp(0.0, 1.0);
|
|
opacity = CurvedAnimation(
|
|
parent: widget.route.animation!, curve: Interval(start, end));
|
|
}
|
|
Widget child = FadeTransition(
|
|
opacity: opacity,
|
|
child: InkWell(
|
|
autofocus: widget.itemIndex == widget.route.selectedIndex,
|
|
onTap: _handleOnTap,
|
|
onFocusChange: _handleFocusChange,
|
|
child: Container(
|
|
padding: widget.padding,
|
|
child: widget.route.items[widget.itemIndex],
|
|
),
|
|
),
|
|
);
|
|
if (kIsWeb) {
|
|
child = Shortcuts(
|
|
shortcuts: _webShortcuts,
|
|
child: child,
|
|
);
|
|
}
|
|
return child;
|
|
}
|
|
}
|
|
|
|
class _DropdownMenu<T> extends StatefulWidget {
|
|
const _DropdownMenu({
|
|
Key? key,
|
|
this.padding,
|
|
required this.route,
|
|
required this.buttonRect,
|
|
required this.constraints,
|
|
this.dropdownColor,
|
|
}) : super(key: key);
|
|
|
|
final _DropdownRoute<T> route;
|
|
final EdgeInsets? padding;
|
|
final Rect buttonRect;
|
|
final BoxConstraints constraints;
|
|
final Color? dropdownColor;
|
|
|
|
@override
|
|
_DropdownMenuState<T> createState() => _DropdownMenuState<T>();
|
|
}
|
|
|
|
class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
|
|
late CurvedAnimation _fadeOpacity;
|
|
late CurvedAnimation _resize;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fadeOpacity = CurvedAnimation(
|
|
parent: widget.route.animation!,
|
|
curve: const Interval(0.0, 0.25),
|
|
reverseCurve: const Interval(0.75, 1.0),
|
|
);
|
|
_resize = CurvedAnimation(
|
|
parent: widget.route.animation!,
|
|
curve: const Interval(0.25, 0.5),
|
|
reverseCurve: const Threshold(0.0),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMaterialLocalizations(context));
|
|
final localizations = MaterialLocalizations.of(context);
|
|
final route = widget.route;
|
|
final children = <Widget>[
|
|
for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex)
|
|
_DropdownMenuItemButton<T>(
|
|
route: widget.route,
|
|
padding: widget.padding,
|
|
buttonRect: widget.buttonRect,
|
|
constraints: widget.constraints,
|
|
itemIndex: itemIndex,
|
|
),
|
|
];
|
|
|
|
return FadeTransition(
|
|
opacity: _fadeOpacity,
|
|
child: CustomPaint(
|
|
painter: _DropdownMenuPainter(
|
|
color: widget.dropdownColor ?? Theme.of(context).canvasColor,
|
|
elevation: route.elevation,
|
|
selectedIndex: route.selectedIndex,
|
|
resize: _resize,
|
|
getSelectedItemOffset: () => route.getItemOffset(route.selectedIndex),
|
|
),
|
|
child: Semantics(
|
|
scopesRoute: true,
|
|
namesRoute: true,
|
|
explicitChildNodes: true,
|
|
label: localizations.popupMenuLabel,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
textStyle: route.style,
|
|
child: ScrollConfiguration(
|
|
behavior: ScrollConfiguration.of(context).copyWith(
|
|
overscroll: false,
|
|
physics: const ClampingScrollPhysics(),
|
|
platform: Theme.of(context).platform,
|
|
),
|
|
child: PrimaryScrollController(
|
|
controller: widget.route.scrollController!,
|
|
child: Scrollbar(
|
|
thumbVisibility: true,
|
|
child: ListView(
|
|
padding: kMaterialListPadding,
|
|
shrinkWrap: true,
|
|
children: children,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
|
|
_DropdownMenuRouteLayout({
|
|
required this.buttonRect,
|
|
required this.route,
|
|
required this.textDirection,
|
|
});
|
|
|
|
final Rect buttonRect;
|
|
final _DropdownRoute<T> route;
|
|
final TextDirection? textDirection;
|
|
|
|
@override
|
|
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
|
var maxHeight = max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight);
|
|
if (route.menuMaxHeight != null && route.menuMaxHeight! <= maxHeight) {
|
|
maxHeight = route.menuMaxHeight!;
|
|
}
|
|
final width = min(constraints.maxWidth, buttonRect.width);
|
|
return BoxConstraints(
|
|
minWidth: width,
|
|
maxWidth: width,
|
|
minHeight: 0.0,
|
|
maxHeight: maxHeight,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Offset getPositionForChild(Size size, Size childSize) {
|
|
final menuLimits =
|
|
route.getMenuLimits(buttonRect, size.height, route.selectedIndex);
|
|
|
|
assert(() {
|
|
final container = Offset.zero & size;
|
|
if (container.intersect(buttonRect) == buttonRect) {
|
|
assert(menuLimits.top >= 0.0);
|
|
assert(menuLimits.top + menuLimits.height <= size.height);
|
|
}
|
|
return true;
|
|
}());
|
|
assert(textDirection != null);
|
|
final double left;
|
|
switch (textDirection!) {
|
|
case TextDirection.rtl:
|
|
left = buttonRect.right.clamp(0.0, size.width) - childSize.width;
|
|
break;
|
|
case TextDirection.ltr:
|
|
left = buttonRect.left.clamp(0.0, size.width - childSize.width);
|
|
break;
|
|
}
|
|
|
|
return Offset(left, menuLimits.top);
|
|
}
|
|
|
|
@override
|
|
bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) {
|
|
return buttonRect != oldDelegate.buttonRect ||
|
|
textDirection != oldDelegate.textDirection;
|
|
}
|
|
}
|
|
|
|
@immutable
|
|
class _DropdownRouteResult<T> {
|
|
const _DropdownRouteResult(this.result);
|
|
|
|
final T? result;
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is _DropdownRouteResult<T> && other.result == result;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => result.hashCode;
|
|
}
|
|
|
|
class _MenuLimits {
|
|
const _MenuLimits(this.top, this.bottom, this.height, this.scrollOffset);
|
|
final double top;
|
|
final double bottom;
|
|
final double height;
|
|
final double scrollOffset;
|
|
}
|
|
|
|
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
|
|
_DropdownRoute({
|
|
required this.items,
|
|
required this.padding,
|
|
required this.buttonRect,
|
|
required this.selectedIndex,
|
|
this.elevation = 8,
|
|
required this.capturedThemes,
|
|
required this.style,
|
|
this.barrierLabel,
|
|
this.itemHeight,
|
|
this.dropdownColor,
|
|
this.menuMaxHeight,
|
|
required this.menuDirection,
|
|
}) : itemHeights = List<double>.filled(
|
|
items.length, itemHeight ?? kMinInteractiveDimension);
|
|
|
|
final List<_MenuItem<T>> items;
|
|
final EdgeInsetsGeometry padding;
|
|
final Rect buttonRect;
|
|
final int selectedIndex;
|
|
final int elevation;
|
|
final CapturedThemes capturedThemes;
|
|
final TextStyle style;
|
|
final double? itemHeight;
|
|
final Color? dropdownColor;
|
|
final double? menuMaxHeight;
|
|
final DropdownMenuDirection menuDirection;
|
|
|
|
final List<double> itemHeights;
|
|
ScrollController? scrollController;
|
|
|
|
@override
|
|
Duration get transitionDuration => _kDropdownMenuDuration;
|
|
|
|
@override
|
|
bool get barrierDismissible => true;
|
|
|
|
@override
|
|
Color? get barrierColor => null;
|
|
|
|
@override
|
|
final String? barrierLabel;
|
|
|
|
@override
|
|
Widget buildPage(BuildContext context, Animation<double> animation,
|
|
Animation<double> secondaryAnimation) {
|
|
return LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
return _DropdownRoutePage<T>(
|
|
route: this,
|
|
constraints: constraints,
|
|
items: items,
|
|
padding: padding,
|
|
buttonRect: buttonRect,
|
|
selectedIndex: selectedIndex,
|
|
elevation: elevation,
|
|
capturedThemes: capturedThemes,
|
|
style: style,
|
|
dropdownColor: dropdownColor,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _dismiss() {
|
|
if (isActive) {
|
|
navigator?.removeRoute(this);
|
|
}
|
|
}
|
|
|
|
double getItemOffset(int index) {
|
|
var offset = kMaterialListPadding.top;
|
|
if (items.isNotEmpty && index > 0) {
|
|
assert(items.length == itemHeights.length);
|
|
offset += itemHeights
|
|
.sublist(0, index)
|
|
.reduce((double total, double height) => total + height);
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
_MenuLimits getMenuLimits(
|
|
Rect buttonRect, double availableHeight, int index) {
|
|
final maxMenuHeight = availableHeight - 2.0 * _kMenuItemHeight;
|
|
final buttonTop = buttonRect.top;
|
|
final buttonBottom = min(buttonRect.bottom, availableHeight);
|
|
final selectedItemOffset = getItemOffset(index);
|
|
|
|
final topLimit = min(_kMenuItemHeight, buttonTop);
|
|
final bottomLimit = max(availableHeight - _kMenuItemHeight, buttonBottom);
|
|
|
|
var preferredMenuHeight = kMaterialListPadding.vertical;
|
|
if (items.isNotEmpty) {
|
|
preferredMenuHeight +=
|
|
itemHeights.reduce((double total, double height) => total + height);
|
|
}
|
|
|
|
final menuHeight = min(maxMenuHeight, preferredMenuHeight);
|
|
//the next two lines are the main change for reversed dropdown opening
|
|
var menuTop = menuDirection == DropdownMenuDirection.up
|
|
? buttonTop - min((menuMaxHeight ?? menuHeight), menuHeight)
|
|
: (buttonTop - selectedItemOffset) -
|
|
(itemHeights[selectedIndex] - buttonRect.height) / 2.0;
|
|
var menuBottom = menuTop + min((menuMaxHeight ?? menuHeight), menuHeight);
|
|
|
|
if (menuTop < topLimit) {
|
|
menuTop = min(buttonTop, topLimit);
|
|
}
|
|
|
|
if (menuBottom > bottomLimit) {
|
|
menuBottom = max(buttonBottom, bottomLimit);
|
|
menuTop = menuBottom - menuHeight;
|
|
}
|
|
|
|
var scrollOffset = 0.0;
|
|
if (preferredMenuHeight > maxMenuHeight) {
|
|
scrollOffset = max(0.0, selectedItemOffset - (buttonTop - menuTop));
|
|
scrollOffset = min(scrollOffset, preferredMenuHeight - menuHeight);
|
|
}
|
|
|
|
return _MenuLimits(menuTop, menuBottom, menuHeight, scrollOffset);
|
|
}
|
|
}
|
|
|
|
class _DropdownRoutePage<T> extends StatelessWidget {
|
|
const _DropdownRoutePage({
|
|
Key? key,
|
|
required this.route,
|
|
required this.constraints,
|
|
this.items,
|
|
required this.padding,
|
|
required this.buttonRect,
|
|
required this.selectedIndex,
|
|
this.elevation = 8,
|
|
required this.capturedThemes,
|
|
this.style,
|
|
required this.dropdownColor,
|
|
}) : super(key: key);
|
|
|
|
final _DropdownRoute<T> route;
|
|
final BoxConstraints constraints;
|
|
final List<_MenuItem<T>>? items;
|
|
final EdgeInsetsGeometry padding;
|
|
final Rect buttonRect;
|
|
final int selectedIndex;
|
|
final int elevation;
|
|
final CapturedThemes capturedThemes;
|
|
final TextStyle? style;
|
|
final Color? dropdownColor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasDirectionality(context));
|
|
if (route.scrollController == null) {
|
|
final menuLimits =
|
|
route.getMenuLimits(buttonRect, constraints.maxHeight, selectedIndex);
|
|
route.scrollController =
|
|
ScrollController(initialScrollOffset: menuLimits.scrollOffset);
|
|
}
|
|
|
|
final textDirection = Directionality.maybeOf(context);
|
|
final Widget menu = _DropdownMenu<T>(
|
|
route: route,
|
|
padding: padding.resolve(textDirection),
|
|
buttonRect: buttonRect,
|
|
constraints: constraints,
|
|
dropdownColor: dropdownColor,
|
|
);
|
|
|
|
return MediaQuery.removePadding(
|
|
context: context,
|
|
removeTop: true,
|
|
removeBottom: true,
|
|
removeLeft: true,
|
|
removeRight: true,
|
|
child: Builder(
|
|
builder: (BuildContext context) {
|
|
return CustomSingleChildLayout(
|
|
delegate: _DropdownMenuRouteLayout<T>(
|
|
buttonRect: buttonRect,
|
|
route: route,
|
|
textDirection: textDirection,
|
|
),
|
|
child: capturedThemes.wrap(menu),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MenuItem<T> extends SingleChildRenderObjectWidget {
|
|
const _MenuItem({
|
|
Key? key,
|
|
required this.onLayout,
|
|
required this.item,
|
|
}) : super(key: key, child: item);
|
|
|
|
final ValueChanged<Size> onLayout;
|
|
final CustomDropdownMenuItem<T>? item;
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) {
|
|
return _RenderMenuItem(onLayout);
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(
|
|
BuildContext context, covariant _RenderMenuItem renderObject) {
|
|
renderObject.onLayout = onLayout;
|
|
}
|
|
}
|
|
|
|
class _RenderMenuItem extends RenderProxyBox {
|
|
_RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child);
|
|
|
|
ValueChanged<Size> onLayout;
|
|
|
|
@override
|
|
void performLayout() {
|
|
super.performLayout();
|
|
onLayout(size);
|
|
}
|
|
}
|
|
|
|
class _DropdownMenuItemContainer extends StatelessWidget {
|
|
const _DropdownMenuItemContainer({
|
|
Key? key,
|
|
required this.child,
|
|
}) : super(key: key);
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
constraints: const BoxConstraints(minHeight: _kMenuItemHeight),
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class CustomDropdownMenuItem<T> extends _DropdownMenuItemContainer {
|
|
const CustomDropdownMenuItem({
|
|
Key? key,
|
|
this.onTap,
|
|
this.value,
|
|
required Widget child,
|
|
}) : super(key: key, child: child);
|
|
|
|
final VoidCallback? onTap;
|
|
final T? value;
|
|
}
|
|
|
|
class CustomDropdownButtonHideUnderline extends InheritedWidget {
|
|
const CustomDropdownButtonHideUnderline({
|
|
Key? key,
|
|
required Widget child,
|
|
}) : super(key: key, child: child);
|
|
|
|
static bool at(BuildContext context) {
|
|
return context.dependOnInheritedWidgetOfExactType<
|
|
CustomDropdownButtonHideUnderline>() !=
|
|
null;
|
|
}
|
|
|
|
@override
|
|
bool updateShouldNotify(CustomDropdownButtonHideUnderline oldWidget) => false;
|
|
}
|
|
|
|
class CustomDropdownButton<T> extends StatefulWidget {
|
|
CustomDropdownButton({
|
|
Key? key,
|
|
required this.items,
|
|
this.selectedItemBuilder,
|
|
this.value,
|
|
this.hint,
|
|
this.disabledHint,
|
|
this.onChanged,
|
|
this.onTap,
|
|
this.elevation = 8,
|
|
this.style,
|
|
this.underline,
|
|
this.icon,
|
|
this.iconDisabledColor,
|
|
this.iconEnabledColor,
|
|
this.iconSize = 24.0,
|
|
this.isDense = false,
|
|
this.isExpanded = false,
|
|
this.itemHeight = kMinInteractiveDimension,
|
|
this.focusColor,
|
|
this.focusNode,
|
|
this.autofocus = false,
|
|
this.dropdownColor,
|
|
this.menuMaxHeight,
|
|
required this.menuDirection,
|
|
}) : assert(
|
|
items == null ||
|
|
items.isEmpty ||
|
|
value == null ||
|
|
items.where((CustomDropdownMenuItem<T> item) {
|
|
return item.value == value;
|
|
}).length ==
|
|
1,
|
|
"There should be exactly one item with [DropdownButton]'s value: "
|
|
'$value. \n'
|
|
'Either zero or 2 or more [DropdownMenuItem]s were detected '
|
|
'with the same value',
|
|
),
|
|
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
|
|
super(key: key);
|
|
|
|
final DropdownMenuDirection menuDirection;
|
|
final List<CustomDropdownMenuItem<T>>? items;
|
|
final T? value;
|
|
final Widget? hint;
|
|
final Widget? disabledHint;
|
|
final ValueChanged<T?>? onChanged;
|
|
final VoidCallback? onTap;
|
|
final DropdownButtonBuilder? selectedItemBuilder;
|
|
final int elevation;
|
|
final TextStyle? style;
|
|
final Widget? underline;
|
|
final Widget? icon;
|
|
final Color? iconDisabledColor;
|
|
final Color? iconEnabledColor;
|
|
final double iconSize;
|
|
final bool isDense;
|
|
final bool isExpanded;
|
|
final double? itemHeight;
|
|
final Color? focusColor;
|
|
final FocusNode? focusNode;
|
|
final bool autofocus;
|
|
final Color? dropdownColor;
|
|
final double? menuMaxHeight;
|
|
|
|
@override
|
|
_DropdownButtonState<T> createState() => _DropdownButtonState<T>();
|
|
}
|
|
|
|
class _DropdownButtonState<T> extends State<CustomDropdownButton<T>>
|
|
with WidgetsBindingObserver {
|
|
int? _selectedIndex;
|
|
_DropdownRoute<T>? _dropdownRoute;
|
|
Orientation? _lastOrientation;
|
|
FocusNode? _internalNode;
|
|
FocusNode? get focusNode => widget.focusNode ?? _internalNode;
|
|
bool _hasPrimaryFocus = false;
|
|
late Map<Type, Action<Intent>> _actionMap;
|
|
late FocusHighlightMode _focusHighlightMode;
|
|
|
|
FocusNode _createFocusNode() {
|
|
return FocusNode(debugLabel: '${widget.runtimeType}');
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_updateSelectedIndex();
|
|
if (widget.focusNode == null) {
|
|
_internalNode ??= _createFocusNode();
|
|
}
|
|
_actionMap = <Type, Action<Intent>>{
|
|
ActivateIntent: CallbackAction<ActivateIntent>(
|
|
onInvoke: (ActivateIntent intent) => _handleTap(),
|
|
),
|
|
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
|
|
onInvoke: (ButtonActivateIntent intent) => _handleTap(),
|
|
),
|
|
};
|
|
focusNode!.addListener(_handleFocusChanged);
|
|
final focusManager = WidgetsBinding.instance.focusManager;
|
|
_focusHighlightMode = focusManager.highlightMode;
|
|
focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_removeDropdownRoute();
|
|
WidgetsBinding.instance.focusManager
|
|
.removeHighlightModeListener(_handleFocusHighlightModeChange);
|
|
focusNode!.removeListener(_handleFocusChanged);
|
|
_internalNode?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _removeDropdownRoute() {
|
|
_dropdownRoute?._dismiss();
|
|
_dropdownRoute = null;
|
|
_lastOrientation = null;
|
|
}
|
|
|
|
void _handleFocusChanged() {
|
|
if (_hasPrimaryFocus != focusNode!.hasPrimaryFocus) {
|
|
this.setState(() {
|
|
_hasPrimaryFocus = focusNode!.hasPrimaryFocus;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
this.setState(() {
|
|
_focusHighlightMode = mode;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(CustomDropdownButton<T> oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.focusNode != oldWidget.focusNode) {
|
|
oldWidget.focusNode?.removeListener(_handleFocusChanged);
|
|
if (widget.focusNode == null) {
|
|
_internalNode ??= _createFocusNode();
|
|
}
|
|
_hasPrimaryFocus = focusNode!.hasPrimaryFocus;
|
|
focusNode!.addListener(_handleFocusChanged);
|
|
}
|
|
_updateSelectedIndex();
|
|
}
|
|
|
|
void _updateSelectedIndex() {
|
|
if (widget.items == null ||
|
|
widget.items!.isEmpty ||
|
|
(widget.value == null &&
|
|
widget.items!
|
|
.where((CustomDropdownMenuItem<T> item) =>
|
|
item.value == widget.value)
|
|
.isEmpty)) {
|
|
_selectedIndex = null;
|
|
return;
|
|
}
|
|
|
|
assert(widget.items!
|
|
.where(
|
|
(CustomDropdownMenuItem<T> item) => item.value == widget.value)
|
|
.length ==
|
|
1);
|
|
for (var itemIndex = 0; itemIndex < widget.items!.length; itemIndex++) {
|
|
if (widget.items![itemIndex].value == widget.value) {
|
|
_selectedIndex = itemIndex;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
TextStyle? get _textStyle =>
|
|
widget.style ?? Theme.of(context).textTheme.subtitle1;
|
|
|
|
void _handleTap() {
|
|
final textDirection = Directionality.maybeOf(context);
|
|
final menuMargin = ButtonTheme.of(context).alignedDropdown
|
|
? _kAlignedMenuMargin
|
|
: _kUnalignedMenuMargin;
|
|
|
|
final menuItems = <_MenuItem<T>>[
|
|
for (int index = 0; index < widget.items!.length; index += 1)
|
|
_MenuItem<T>(
|
|
item: widget.items![index],
|
|
onLayout: (Size size) {
|
|
if (_dropdownRoute == null) {
|
|
return;
|
|
}
|
|
|
|
_dropdownRoute!.itemHeights[index] = size.height;
|
|
},
|
|
),
|
|
];
|
|
|
|
final navigator = Navigator.of(context);
|
|
assert(_dropdownRoute == null);
|
|
final itemBox = context.findRenderObject()! as RenderBox;
|
|
final itemRect = itemBox.localToGlobal(Offset.zero,
|
|
ancestor: navigator.context.findRenderObject()) &
|
|
itemBox.size;
|
|
|
|
_dropdownRoute = _DropdownRoute<T>(
|
|
items: menuItems,
|
|
buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
|
|
padding: _kMenuItemPadding.resolve(textDirection),
|
|
selectedIndex: _selectedIndex ?? 0,
|
|
elevation: widget.elevation,
|
|
capturedThemes:
|
|
InheritedTheme.capture(from: context, to: navigator.context),
|
|
style: _textStyle!,
|
|
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
|
itemHeight: widget.itemHeight,
|
|
dropdownColor: widget.dropdownColor,
|
|
menuMaxHeight: widget.menuMaxHeight,
|
|
menuDirection: widget.menuDirection,
|
|
);
|
|
|
|
navigator
|
|
.push(_dropdownRoute!)
|
|
.then<void>((_DropdownRouteResult<T>? newValue) {
|
|
_removeDropdownRoute();
|
|
if (!mounted || newValue == null) {
|
|
return;
|
|
}
|
|
widget.onChanged?.call(newValue.result);
|
|
});
|
|
|
|
widget.onTap?.call();
|
|
}
|
|
|
|
double get _denseButtonHeight {
|
|
final fontSize = _textStyle!.fontSize ??
|
|
Theme.of(context).textTheme.subtitle1!.fontSize!;
|
|
return max(fontSize, max(widget.iconSize, _kDenseButtonHeight));
|
|
}
|
|
|
|
Color get _iconColor {
|
|
if (_enabled) {
|
|
if (widget.iconEnabledColor != null) {
|
|
return widget.iconEnabledColor!;
|
|
}
|
|
|
|
switch (Theme.of(context).brightness) {
|
|
case Brightness.light:
|
|
return Colors.grey.shade700;
|
|
case Brightness.dark:
|
|
return Colors.white70;
|
|
}
|
|
} else {
|
|
if (widget.iconDisabledColor != null) {
|
|
return widget.iconDisabledColor!;
|
|
}
|
|
|
|
switch (Theme.of(context).brightness) {
|
|
case Brightness.light:
|
|
return Colors.grey.shade400;
|
|
case Brightness.dark:
|
|
return Colors.white10;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool get _enabled =>
|
|
widget.items != null &&
|
|
widget.items!.isNotEmpty &&
|
|
widget.onChanged != null;
|
|
|
|
Orientation _getOrientation(BuildContext context) {
|
|
var result = MediaQuery.maybeOf(context)?.orientation;
|
|
if (result == null) {
|
|
final Size size = window.physicalSize;
|
|
result = size.width > size.height
|
|
? Orientation.landscape
|
|
: Orientation.portrait;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool get _showHighlight {
|
|
switch (_focusHighlightMode) {
|
|
case FocusHighlightMode.touch:
|
|
return false;
|
|
case FocusHighlightMode.traditional:
|
|
return _hasPrimaryFocus;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMaterial(context));
|
|
assert(debugCheckHasMaterialLocalizations(context));
|
|
final newOrientation = _getOrientation(context);
|
|
_lastOrientation ??= newOrientation;
|
|
if (newOrientation != _lastOrientation) {
|
|
_removeDropdownRoute();
|
|
_lastOrientation = newOrientation;
|
|
}
|
|
|
|
final items = widget.selectedItemBuilder == null
|
|
? (widget.items != null ? List<Widget>.from(widget.items!) : <Widget>[])
|
|
: List<Widget>.from(widget.selectedItemBuilder!(context));
|
|
|
|
int? hintIndex;
|
|
if (widget.hint != null || (!_enabled && widget.disabledHint != null)) {
|
|
var displayedHint =
|
|
_enabled ? widget.hint! : widget.disabledHint ?? widget.hint!;
|
|
if (widget.selectedItemBuilder == null) {
|
|
displayedHint = _DropdownMenuItemContainer(child: displayedHint);
|
|
}
|
|
|
|
hintIndex = items.length;
|
|
items.add(DefaultTextStyle(
|
|
style: _textStyle!.copyWith(color: Theme.of(context).hintColor),
|
|
child: IgnorePointer(
|
|
ignoringSemantics: false,
|
|
child: displayedHint,
|
|
),
|
|
));
|
|
}
|
|
|
|
final padding = ButtonTheme.of(context).alignedDropdown
|
|
? _kAlignedButtonPadding
|
|
: _kUnalignedButtonPadding;
|
|
|
|
final Widget innerItemsWidget;
|
|
if (items.isEmpty) {
|
|
innerItemsWidget = Container();
|
|
} else {
|
|
innerItemsWidget = IndexedStack(
|
|
index: _selectedIndex ?? hintIndex,
|
|
alignment: AlignmentDirectional.centerStart,
|
|
children: widget.isDense
|
|
? items
|
|
: items.map((Widget item) {
|
|
return widget.itemHeight != null
|
|
? SizedBox(height: widget.itemHeight, child: item)
|
|
: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[item]);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
const defaultIcon = Icon(Icons.arrow_drop_down);
|
|
|
|
Widget result = DefaultTextStyle(
|
|
style: _enabled
|
|
? _textStyle!
|
|
: _textStyle!.copyWith(color: Theme.of(context).disabledColor),
|
|
child: Container(
|
|
decoration: _showHighlight
|
|
? BoxDecoration(
|
|
color: widget.focusColor ?? Theme.of(context).focusColor,
|
|
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
|
)
|
|
: null,
|
|
padding: padding.resolve(Directionality.of(context)),
|
|
height: widget.isDense ? _denseButtonHeight : null,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
if (widget.isExpanded)
|
|
Expanded(child: innerItemsWidget)
|
|
else
|
|
innerItemsWidget,
|
|
IconTheme(
|
|
data: IconThemeData(
|
|
color: _iconColor,
|
|
size: widget.iconSize,
|
|
),
|
|
child: widget.icon ?? defaultIcon,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
if (!CustomDropdownButtonHideUnderline.at(context)) {
|
|
final bottom = (widget.isDense || widget.itemHeight == null) ? 0.0 : 8.0;
|
|
result = Stack(
|
|
children: <Widget>[
|
|
result,
|
|
Positioned(
|
|
left: 0.0,
|
|
right: 0.0,
|
|
bottom: bottom,
|
|
child: widget.underline ??
|
|
Container(
|
|
height: 1.0,
|
|
decoration: const BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: Color(0xFFBDBDBD),
|
|
width: 0.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Semantics(
|
|
button: true,
|
|
child: Actions(
|
|
actions: _actionMap,
|
|
child: Focus(
|
|
canRequestFocus: _enabled,
|
|
focusNode: focusNode,
|
|
autofocus: widget.autofocus,
|
|
child: GestureDetector(
|
|
onTap: _enabled ? _handleTap : null,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: result,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|