Implementing circular context menu in flutter [closed] - flutter

Closed. This question needs to be more focused. It is not currently accepting answers.
Want to improve this question? Update the question so it focuses on one problem only by editing this post.
Closed 1 year ago.
Improve this question
I have already searched for circular menu packages for flutter and all of them have fabs arranged radially. I want the UI such that wherever the user taps(onhold) a circular menu will popup with buttons divided in sectors.

Here is a quick playground for such a Radial Context Menu.
It uses the concepts of State Management, Providers, Overlays...
I tried to keep it under 300 lines of code.
Structure of the project:
Domain Layer
For the purpose of this demo, I used Boxes defined by their id, name and color.
The Radial Menu is defined by a MenuConfig and MenuActions
State Management Layer
For the boxes, I used a boxesProvider providing a list of boxes and the possibility to update their color.
For the menu, I defined a menuProvider as a StateNotifierProvider.family as well as a ScopedProvider for the current item on which my Radial Menu is applied.
Presentation Layer
The structure of the Widget Tree is as follows:
Providercope
> MaterialApp
> HomePage
> Scaffold
> GridView
> BoxWidget
> ProviderScope [contextIdProvider.overrideWithValue(box.id)]
> ContextMenuDetector
The ContextMenuDetector is a GestureDetector that create the RadialMenu inside an OverlayEntry and manages the menu user experience thanks to olLongPressStart, olLongPressMoveUpdate, and olLongPressUp
I let you discover the full source code:
Full source code:
import 'dart:math' show pi, cos, sin;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
part '66483259.radial_menu.freezed.dart';
void main() {
runApp(ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Radial Menu Demo',
home: HomePage(),
)));
}
class HomePage extends HookWidget {
#override
Widget build(BuildContext context) {
final boxes = useProvider(boxesProvider.state);
return Scaffold(
appBar: AppBar(title: Text('Radial Menu Demo')),
body: GridView.count(
crossAxisCount: 3,
children: boxes.map((box) => BoxWidget(box: box)).toList(),
),
);
}
}
class BoxWidget extends StatelessWidget {
final Box box;
const BoxWidget({Key key, this.box}) : super(key: key);
#override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [contextIdProvider.overrideWithValue(box.id)],
child: ContextMenuDetector(
child: Container(
decoration: BoxDecoration(
color: box.color,
border: Border.all(color: Colors.black87, width: 2.0),
),
child: Text(box.name),
),
),
);
}
}
class ContextMenuDetector extends HookWidget {
final Widget child;
const ContextMenuDetector({Key key, this.child}) : super(key: key);
#override
Widget build(BuildContext context) {
final contextId = useProvider(contextIdProvider);
final _menuConfig = useProvider(menuProvider(contextId).state);
final _offsetCorrection = useState(Offset.zero);
final _overlayEntry = useState<OverlayEntry>(null);
OverlayEntry _createMenu({Offset offset}) => OverlayEntry(
builder: (BuildContext overlayContext) {
return ProviderScope(
overrides: [contextIdProvider.overrideWithValue(contextId)],
child: Positioned(
left: offset.dx - kMenuRadius,
top: offset.dy - kMenuRadius,
child: RadialMenu(),
),
);
},
);
void _showMenu({Offset offset}) {
_overlayEntry.value = _createMenu(offset: offset);
Overlay.of(context).insert(_overlayEntry.value);
}
void _hideMenu() {
_overlayEntry.value?.remove();
}
return GestureDetector(
onLongPressStart: (details) {
final menuOffset = Offset(
details.globalPosition.dx.clamp(
kMenuRadius, MediaQuery.of(context).size.width - kMenuRadius),
details.globalPosition.dy.clamp(
kMenuRadius, MediaQuery.of(context).size.height - kMenuRadius),
);
_offsetCorrection.value = details.globalPosition - menuOffset;
_showMenu(offset: menuOffset);
},
onLongPressMoveUpdate: (details) {
final offset = details.localOffsetFromOrigin + _offsetCorrection.value;
if (offset.distance <= kMenuRadius) {
final sextant =
(((offset.direction / pi + 2 + 1 / 6) % 2) * 3).floor();
if (sextant < 4) {
context.read(menuProvider(contextId)).selectAction(sextant);
return;
}
}
context.read(menuProvider(contextId)).selectAction(-1);
},
onLongPressUp: () {
if (_menuConfig.selectedAction >= 0) {
_menuConfig.currentAction?.callback?.call(context, contextId);
}
_hideMenu();
},
child: child,
);
}
}
class RadialMenu extends HookWidget {
#override
Widget build(BuildContext context) {
final contextId = useProvider(contextIdProvider);
final config = useProvider(menuProvider(contextId).state);
return Material(
color: Colors.transparent,
child: Container(
width: 2 * kMenuRadius,
height: 2 * kMenuRadius,
decoration: BoxDecoration(
color: Colors.grey.shade200,
shape: BoxShape.circle,
),
child: CustomPaint(
painter: RadialMenuPainter(config: config),
size: Size(2 * kMenuRadius, 2 * kMenuRadius),
child: Stack(
children: [
Positioned(
top: .4 * kMenuRadius,
left: .5 * kMenuRadius,
right: .5 * kMenuRadius,
child: Text('Menu $contextId', textAlign: TextAlign.center),
),
...config.actions.asMap().entries.map(
(action) {
final angle = pi * action.key / 3;
return Positioned(
left: kMenuRadius * (.6 + .5 * cos(angle)),
top: kMenuRadius * (.6 + .5 * sin(angle)),
child: SizedBox(
width: .8 * kMenuRadius,
height: .8 * kMenuRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(action.value.label,
style: TextStyle(fontSize: 12.0)),
Icon(action.value.iconData, size: 16.0),
],
),
),
);
},
),
],
),
),
),
);
}
}
class RadialMenuPainter extends CustomPainter {
final MenuConfig config;
RadialMenuPainter({this.config});
#override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()
..color = config.color
..style = PaintingStyle.stroke
..strokeWidth = size.shortestSide * .1;
canvas.drawCircle(
Offset(size.width / 2, size.height / 2), size.shortestSide / 2, paint);
config.actions.asMap().entries.forEach((action) {
Paint paint = Paint()
..color = action.key == config.selectedAction
? action.value.color
: action.value.color.withOpacity(.4)
..style = PaintingStyle.fill;
Path path = Path()
..moveTo(size.width / 2, size.height / 2)
..arcTo(
Rect.fromLTWH(size.width * .1, size.height * .1, size.width * .8,
size.height * .8),
pi * (action.key / 3 - 1 / 6),
pi / 3,
false)
..close();
canvas.drawPath(path, paint);
});
}
#override
bool shouldRepaint(covariant RadialMenuPainter oldDelegate) =>
oldDelegate.config.selectedAction != config.selectedAction;
}
final boxesProvider =
StateNotifierProvider<BoxesNotifier>((ref) => BoxesNotifier(24));
class BoxesNotifier extends StateNotifier<List<Box>> {
BoxesNotifier(int nbBoxes)
: super(
List.generate(
nbBoxes,
(index) => Box(id: index, name: 'Box $index', color: Colors.white),
),
);
updateBoxColor(int id, Color color) {
state = [...state]..[id] = state[id].copyWith(color: color);
}
}
#freezed
abstract class Box with _$Box {
const factory Box({int id, String name, Color color}) = _Box;
}
final contextIdProvider =
ScopedProvider<int>((ref) => throw UnimplementedError());
final menuProvider = StateNotifierProvider.family<MenuNotifier, int>(
(ref, id) => MenuNotifier(menuConfig));
class MenuNotifier extends StateNotifier<MenuConfig> {
MenuNotifier(MenuConfig state) : super(state);
void selectAction(int index) {
state = state.copyWith(selectedAction: index);
}
}
#freezed
abstract class MenuConfig implements _$MenuConfig {
const factory MenuConfig({
#Default(Colors.white) Color color,
#Default([]) List<MenuAction> actions,
int selectedAction,
}) = _RadialMenuConfig;
const MenuConfig._();
MenuAction get currentAction => actions[selectedAction];
}
#freezed
abstract class MenuAction with _$MenuAction {
const factory MenuAction(
{String label,
IconData iconData,
void Function(BuildContext, int id) callback,
Color color}) = _MenuAction;
}
final menuConfig = MenuConfig(
color: Colors.lightBlue.shade200,
actions: [
MenuAction(
label: 'Cut',
iconData: Icons.cut,
color: Colors.red.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.red.shade200),
),
MenuAction(
label: 'Copy',
iconData: Icons.copy,
color: Colors.green.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.green.shade200),
),
MenuAction(
label: 'Paste',
iconData: Icons.paste,
color: Colors.blue.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.blue.shade200),
),
MenuAction(
label: 'Undo',
iconData: Icons.undo,
color: Colors.indigo.shade200,
callback: (context, id) => context
.read(boxesProvider)
.updateBoxColor(id, Colors.indigo.shade200),
),
],
);
const double kMenuRadius = 100.0;
Package Dependencies
Riverpod (Flutter Hooks flavor) for State Management
Freezed for Domain classes immutability

For apps nowadays, it is a common UX practice to radially pop-up more buttons for extra actions that the user can perform.
This, however, is useful for presenting multiple actions to the user, but is not useful in cases where you want the user to perform a primary action.
Still, if your case fits the need, be sure to check out this tutorial from Jeff #firehsip.io

Related

Progressive Menu in Flutter (Hide Overflowing Elements in Dropdown)

I'm building a resizable Flutter desktop app and I'm wondering how it would be possible to automatically hide overflowing items in a menu (e.g. a Row) and making them visible in a "More" dropdown instead.
Conceptual Sample Images (Source: https://css-tricks.com/container-adapting-tabs-with-more-button/)
A lot of width available (shows all items):
Less width available (hides items in dropdown):
Thanks in advance!
Update: I had the idea of using a Wrap-element for it, but then I've had the following problems:
How do I limit the Wrap to only show one line of children? (related to https://github.com/flutter/flutter/issues/65331)
How do I get the info on which of the elements are on line 1 and which are hidden.
Maybe you can try creating a MultiChildRenderObjectWidget in which you can calculate the children size before painting. It's more complicated though because your like making a custom Row class.
I created a sample but it may still contain bugs and need some improvements.
Sample...
threshold
collapsible_menu_bar.dart
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class MenuBar extends StatelessWidget {
MenuBar({
required this.onItemPressed,
required this.children,
this.minItemWidth = 110,
this.minMoreItemsWidth = 70,
Key? key,
}) : super(key: key);
final ValueChanged<int> onItemPressed;
final List<MenuBarItem> children;
final double minItemWidth;
final double minMoreItemsWidth;
static int tmpStartIndex = 0;
#override
Widget build(BuildContext context) {
return CollapsibleMenuBar(
onCollapseIndex: (int startIndex) {
if (tmpStartIndex == startIndex) {
return;
}
tmpStartIndex = startIndex;
},
minItemWidth: minItemWidth,
minMoreItemsWidth: minMoreItemsWidth,
children: [
...children,
PopupMenuButton(
offset: const Offset(0, 40),
color: Colors.red,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
color: Colors.amber,
child: const Text('More'),
),
itemBuilder: (_) => children
.sublist(tmpStartIndex)
.map((e) => PopupMenuItem(child: e))
.toList(),
),
],
);
}
}
///
///
///
class MenuBarItem extends StatelessWidget {
const MenuBarItem({
required this.onPressed,
required this.text,
Key? key,
}) : super(key: key);
final VoidCallback? onPressed;
final String text;
#override
Widget build(BuildContext context) {
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
backgroundColor: Colors.red,
padding: const EdgeInsets.all(20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
),
child: Text(
text,
style: const TextStyle(color: Colors.white),
),
);
}
}
///
///
///
class CollapsibleMenuBar extends MultiChildRenderObjectWidget {
CollapsibleMenuBar({
required this.onCollapseIndex,
required List<Widget> children,
required this.minItemWidth,
required this.minMoreItemsWidth,
Key? key,
}) : super(key: key, children: children);
final ValueChanged<int> onCollapseIndex;
final double minItemWidth;
final double minMoreItemsWidth;
#override
RenderObject createRenderObject(BuildContext context) {
return RenderCollapsibleMenuBar(
onCollapseIndex,
minItemWidth,
minMoreItemsWidth,
);
}
}
///
///
///
class CollapsibleMenuBarParentData extends ContainerBoxParentData<RenderBox> {}
///
///
///
class RenderCollapsibleMenuBar extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, CollapsibleMenuBarParentData>,
RenderBoxContainerDefaultsMixin<RenderBox,
CollapsibleMenuBarParentData> {
RenderCollapsibleMenuBar(
this.onCollapseIndex,
this.minItemWidth,
this.minMoreItemsWidth,
);
final ValueChanged<int> onCollapseIndex;
final double minItemWidth;
final double minMoreItemsWidth;
#override
void setupParentData(covariant RenderObject child) {
if (child.parentData is! CollapsibleMenuBarParentData) {
child.parentData = CollapsibleMenuBarParentData();
}
}
#override
void performLayout() {
// Make width of children equal.
final double childWidth = max(
constraints.maxWidth / (childCount - 1),
minItemWidth,
);
double totalWidth = 0;
double totalHeight = 0;
RenderBox? child = firstChild;
Offset childOffset = Offset(0, 0);
int childIdx = 0;
while (child != null && child != lastChild) {
CollapsibleMenuBarParentData childParentData =
child.parentData as CollapsibleMenuBarParentData;
// Set child's dimension.
child.layout(
BoxConstraints(
minWidth: childWidth,
maxWidth: childWidth,
maxHeight: constraints.maxHeight,
),
parentUsesSize: true,
);
// If the total width exceeds the max screen width,
// display "more" item.
if (totalWidth + child.size.width > constraints.maxWidth) {
// Set overflow item dimension to 0.
child.layout(
BoxConstraints(
minWidth: 0,
maxWidth: 0,
maxHeight: constraints.maxHeight,
),
parentUsesSize: true,
);
// Get popup menu item.
child = lastChild!;
childParentData = child.parentData as CollapsibleMenuBarParentData;
// Set popup menu item's dimension. Will cover the remaining width.
child.layout(
BoxConstraints(
minWidth: constraints.maxWidth - totalWidth,
maxWidth: constraints.maxWidth - totalWidth,
maxHeight: constraints.maxHeight,
),
parentUsesSize: true,
);
}
if (child == lastChild) {
// If "more" item's width is below threshold, hide left item.
if (child.size.width <= minMoreItemsWidth) {
childIdx--;
RenderBox nthChild = getChildrenAsList()[childIdx];
// Hide left item of "more" item.
totalWidth -= nthChild.size.width;
childOffset -= Offset(nthChild.size.width, 0);
nthChild.layout(
BoxConstraints(
minWidth: 0,
maxWidth: 0,
maxHeight: constraints.maxHeight,
),
parentUsesSize: true,
);
// Resize "more" item.
child.layout(
BoxConstraints(
minWidth: constraints.maxWidth - totalWidth,
maxWidth: constraints.maxWidth - totalWidth,
maxHeight: constraints.maxHeight,
),
parentUsesSize: true,
);
}
// Update the start index of children to be displayed
// in "more" items.
onCollapseIndex(childIdx);
}
totalWidth += child.size.width;
totalHeight = max(totalHeight, child.size.height);
childParentData.offset = Offset(childOffset.dx, 0);
childOffset += Offset(child.size.width, 0);
if (child != lastChild) {
childIdx++;
}
child = childParentData.nextSibling;
}
// If all children is displayed except for "more" item.
if (childIdx == childCount - 1) {
// Set the layout of popup button to size 0.
lastChild!.layout(BoxConstraints(
minWidth: 0,
maxWidth: 0,
maxHeight: constraints.maxHeight,
));
}
size = Size(totalWidth, totalHeight);
}
#override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
#override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
Usage:
class HomePage extends StatelessWidget {
final List<String> _data = const <String>[
'Falkenberg',
'Braga',
'Stockholm',
'Trnnava',
'Plodiv',
'Klaipeda',
'Punta Cana',
'Lisbon',
];
#override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(20),
child: MenuBar(
onItemPressed: (int i) {},
children: _data
.map((String data) => MenuBarItem(
onPressed: () => print(data),
text: data,
))
.toList(),
),
),
],
),
);
}
}
Try watching this video to learn more how it works.

Floor plan in flutter

i'm trying to come up with best way to draw a floor plan in flutter, something like these images, but it would be for regals in one concrete shop, instead of plan of shopping centre with multiple shops.
floor plan 1
floor plan 2
i decided rectangles would be sufficient enough for now and i have multiple ideas on how to execute, but no idea which one is the best. or maybe there is even better one i have not thought of
1. using custom painter
regals have attributes: ax, ay, bx, by, so they go from point a (left bottom) to b (right upper)
code like this
final rect = Rect.fromPoints(
Offset(regal.ax.toDouble(), size.height - regal.ay.toDouble()),
Offset(regal.bx.toDouble(), size.height - regal.by.toDouble()),
);
this is good because it is flexible, there is pretty much unlimited range of options, but using CustomPainter is a bit buggy in my case, alongside with Transform and GestureDetector it bugs out sometimes and instead of clicking on "buttons" you need to track where user clicked, ehm, tapped.
2. using gridView?
i dont have thought this thru as much as first option, but big plus would be using styled buttons as regals, instead of rectangles.
possible problems would be button sizing, if one regal would be times bigger than others.
regal attributes would be order on x axis, order on y axis, x flex (for example 3 as 3 times of base size), y flex
i think i have not thought of the best solution yet.
what would it be?
Here is a quick playground using a Stack of Regals who are just Containers in this quick implementation under 250 lines of code.
Click the FloatActionButton to create random Regal. Then, you can define the position of each Regal and its Size, within the limit of the Floor Plan and Max/min Regal Size.
In this quick implementation, the position of a Regal can be defined both with Gestures or Sliders; while its size can only be defined using the sliders.
Package Dependencies
Riverpod (Flutter Hooks flavor) for State Management
Freezed for Domain classes immutability
Full Source Code (222 lines)
import 'dart:math' show Random;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
part '66478145.floor_plan.freezed.dart';
void main() {
runApp(
ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
home: HomePage(),
),
),
);
}
class HomePage extends HookWidget {
#override
Widget build(BuildContext context) {
final regals = useProvider(regalsProvider.state);
return Scaffold(
body: Center(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Stack(
children: [
Container(
width: kFloorSize.width,
height: kFloorSize.height,
color: Colors.amber.shade100),
...regals
.map(
(regal) => Positioned(
top: regal.offset.dy,
left: regal.offset.dx,
child: GestureDetector(
child: RegalWidget(regal: regal),
),
),
)
.toList(),
],
),
const SizedBox(width: 16.0),
RegalProperties(),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read(regalsProvider).createRegal(),
child: Icon(Icons.add),
),
);
}
}
class RegalWidget extends HookWidget {
final Regal regal;
const RegalWidget({Key key, this.regal}) : super(key: key);
#override
Widget build(BuildContext context) {
final _previousOffset = useState<Offset>(null);
final _refOffset = useState<Offset>(null);
return GestureDetector(
onTap: () => context.read(selectedRegalIdProvider).state = regal.id,
onPanStart: (details) {
_previousOffset.value = regal.offset;
_refOffset.value = details.localPosition;
},
onPanUpdate: (details) => context.read(regalsProvider).updateRegal(
regal.copyWith(
offset: _previousOffset.value +
details.localPosition -
_refOffset.value),
),
child: Container(
width: regal.size.width,
height: regal.size.height,
color: regal.color,
),
);
}
}
class RegalProperties extends HookWidget {
#override
Widget build(BuildContext context) {
final regal = useProvider(selectedRegalProvider);
return Padding(
padding: EdgeInsets.all(16.0),
child: regal == null
? Text('Click a Regal to start')
: Form(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('WIDTH'),
Slider(
min: kRegalMinSize.width,
max: kRegalMaxSize.width,
value: regal.size.width,
onChanged: (value) => context
.read(regalsProvider)
.updateRegal(
regal.copyWith(size: Size(value, regal.size.height)),
),
),
const SizedBox(height: 16.0),
Text('HEIGHT'),
Slider(
min: kRegalMinSize.height,
max: kRegalMaxSize.height,
value: regal.size.height,
onChanged: (value) => context
.read(regalsProvider)
.updateRegal(
regal.copyWith(size: Size(regal.size.width, value)),
),
),
const SizedBox(height: 16.0),
Text('LEFT'),
Slider(
min: 0,
max: kFloorSize.width - regal.size.width,
value: regal.offset.dx,
onChanged: (value) =>
context.read(regalsProvider).updateRegal(
regal.copyWith(
offset: Offset(value, regal.offset.dy)),
),
),
const SizedBox(height: 16.0),
Text('TOP'),
Slider(
min: 0,
max: kFloorSize.height - regal.size.height,
value: regal.offset.dy,
onChanged: (value) =>
context.read(regalsProvider).updateRegal(
regal.copyWith(
offset: Offset(regal.offset.dx, value)),
),
),
],
),
),
);
}
}
final selectedRegalIdProvider = StateProvider<String>((ref) => null);
final selectedRegalProvider = Provider<Regal>((ref) {
final selectedId = ref.watch(selectedRegalIdProvider).state;
final regals = ref.watch(regalsProvider.state);
return regals.firstWhereOrNull((regal) => regal.id == selectedId);
});
final regalsProvider =
StateNotifierProvider<RegalsNotifier>((ref) => RegalsNotifier());
class RegalsNotifier extends StateNotifier<List<Regal>> {
final Size floorSize;
final Size maxSize;
RegalsNotifier({
this.floorSize = const Size(600, 400),
this.maxSize = const Size(100, 100),
List<Regal> state,
}) : super(state ?? []);
void createRegal() {
state = [...state, Regal.random];
print(state.last);
}
void updateRegal(Regal updated) {
state = state.map((r) => r.id == updated.id ? updated : r).toList();
}
}
#freezed
abstract class Regal implements _$Regal {
const factory Regal({
String id,
Color color,
Offset offset,
Size size,
}) = _Regal;
static Regal get random {
final rnd = Random();
return Regal(
id: DateTime.now().millisecondsSinceEpoch.toString(),
color: Color(0xff555555 + rnd.nextInt(0x777777)),
offset: Offset(
rnd.nextDouble() * (kFloorSize.width - kRegalMaxSize.width),
rnd.nextDouble() * (kFloorSize.height - kRegalMaxSize.height),
),
size: Size(
kRegalMinSize.width +
rnd.nextDouble() * (kRegalMaxSize.width - kRegalMinSize.width),
kRegalMinSize.height +
rnd.nextDouble() * (kRegalMaxSize.height - kRegalMinSize.height),
),
);
}
}
// CONFIG
const kFloorSize = Size(600, 400);
const kRegalMinSize = Size(10, 10);
const kRegalMaxSize = Size(200, 200);

Mimic iOS contact form AppBar

I'm trying to mimic iOS contact form app bar.
expanded
collapsed
Here is where I get so far
Main Screen
class CompanyScreen extends StatefulWidget {
#override
_CompanyScreenState createState() => _CompanyScreenState();
}
class _CompanyScreenState extends State<CompanyScreen> {
#override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
pinned: true,
floating: true,
delegate: SafeAreaPersistentHeaderDelegate(
expandedHeight: 200,
flexibleSpace:
SafeArea(child: Image.asset('assets/images/user.png'))),
),
SliverList(
delegate: SliverChildListDelegate([
TextField(),
]),
)
],
),
);
}
}
SliverHeader
class SafeAreaPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget title;
final Widget flexibleSpace;
final double expandedHeight;
SafeAreaPersistentHeaderDelegate(
{this.title, this.flexibleSpace, this.expandedHeight});
#override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
final Widget appBar = FlexibleSpaceBar.createSettings(
minExtent: minExtent,
maxExtent: maxExtent,
currentExtent: max(minExtent, maxExtent - shrinkOffset),
toolbarOpacity: 1,
child: AppBar(
actions: <Widget>[
Container(
height: 60,
child: FlatButton(
child: Text('Done'),
),
)
],
backgroundColor: Colors.blue,
automaticallyImplyLeading: false,
title: title,
flexibleSpace: (title == null && flexibleSpace != null)
? Semantics(child: flexibleSpace, header: true)
: flexibleSpace,
centerTitle: true,
toolbarOpacity: 1,
bottomOpacity: 1.0),
);
return appBar;
}
#override
double get maxExtent => expandedHeight;
#override
double get minExtent => 80;
#override
bool shouldRebuild(SafeAreaPersistentHeaderDelegate old) {
if (old.flexibleSpace != flexibleSpace) {
return true;
}
return false;
}
}
UPDATE: It all works but I have a problem add the text under the image (Add Photo) and make that text disappear when collapsed. With this solution, if I wrap the image into a column then image expands overflow and doesn't scale.
Requirements:
AppBar and the flex area must be in safe area
Widget with image must have text at the bottom which can be changed dynamically (Add image or Change image) and it must be clickable
The text under the image area must disappear when flex area is collapsed with some transition
Ability to add title in app bar lined up with action buttons
When title in app bar is provided then flex area should scale bellow the title, if not flex area should scale into the title area as on the above image
Any help with this greatly appreciated
I gave it a try.. I'm not an expert on slivers so this solution might not be perfect. I have taken your code as starting point. The column seems to deactivate all scaling so I scaled all manually.
here is your app bar
UPDATE I have tweaked it a little so it feels more like iOS app bar plus I've added extra feature
import 'dart:math';
import 'package:flutter/material.dart';
double _defaultTextHeight = 14;
double _defaultTextPadding = 5;
double _defaultAppBarHeight = 60;
double _defaultMinAppBarHeight = 40;
double _unknownTextValue = 1;
class AppBarSliverHeader extends SliverPersistentHeaderDelegate {
final String title;
final double expandedHeight;
final double safeAreaPadding;
final Widget flexibleImage;
final double flexibleSize;
final String flexibleTitle;
final double flexiblePadding;
final bool flexToTop;
final Function onTap;
final Widget rightButton;
final Widget leftButton;
AppBarSliverHeader(
{this.title,
this.onTap,
this.flexibleImage,
#required this.expandedHeight,
#required this.safeAreaPadding,
this.flexibleTitle = '',
this.flexToTop = false,
this.leftButton,
this.rightButton,
this.flexibleSize = 30,
this.flexiblePadding = 4});
double _textPadding(double shrinkOffset) {
return _defaultTextPadding * _scaleFactor(shrinkOffset);
}
double _widgetPadding(double shrinkOffset) {
double offset;
if (title == null) {
offset = _defaultMinAppBarHeight * _scaleFactor(shrinkOffset);
} else {
if (flexToTop) {
offset = _defaultAppBarHeight * _scaleFactor(shrinkOffset);
} else {
offset = (_defaultAppBarHeight - _defaultMinAppBarHeight) *
_scaleFactor(shrinkOffset) +
_defaultMinAppBarHeight;
}
}
return offset;
}
double _topOffset(double shrinkOffset) {
double offset;
if (title == null) {
offset = safeAreaPadding +
(_defaultMinAppBarHeight * _scaleFactor(shrinkOffset));
} else {
if (flexToTop) {
offset = safeAreaPadding +
(_defaultAppBarHeight * _scaleFactor(shrinkOffset));
} else {
offset = safeAreaPadding +
((_defaultAppBarHeight - _defaultMinAppBarHeight) *
_scaleFactor(shrinkOffset)) +
_defaultMinAppBarHeight;
}
}
return offset;
}
double _calculateWidgetHeight(double shrinkOffset) {
double actualTextHeight = _scaleFactor(shrinkOffset) * _defaultTextHeight +
_textPadding(shrinkOffset) +
_unknownTextValue;
final padding = title == null
? (2 * flexiblePadding)
: flexToTop ? (2 * flexiblePadding) : flexiblePadding;
final trueMinExtent = minExtent - _topOffset(shrinkOffset);
final trueMaxExtent = maxExtent - _topOffset(shrinkOffset);
double minWidgetSize =
trueMinExtent - padding;
double widgetHeight =
((trueMaxExtent - actualTextHeight) - shrinkOffset) - padding;
return widgetHeight >= minWidgetSize ? widgetHeight : minWidgetSize;
}
double _scaleFactor(double shrinkOffset) {
final ratio = (maxExtent - minExtent) / 100;
double percentageHeight = shrinkOffset / ratio;
double limitedPercentageHeight =
percentageHeight >= 100 ? 100 : percentageHeight;
return 1 - (limitedPercentageHeight / 100);
}
Widget _builtContent(BuildContext context, double shrinkOffset) {
_topOffset(shrinkOffset);
return SafeArea(
bottom: false,
child: Semantics(
child: Padding(
padding: title == null
? EdgeInsets.symmetric(vertical: flexiblePadding)
: flexToTop
? EdgeInsets.symmetric(vertical: flexiblePadding)
: EdgeInsets.only(bottom: flexiblePadding),
child: GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
LimitedBox(
maxWidth: _calculateWidgetHeight(shrinkOffset),
maxHeight: _calculateWidgetHeight(shrinkOffset),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(
_calculateWidgetHeight(shrinkOffset))),
color: Colors.white),
child: ClipRRect(
borderRadius: BorderRadius.circular(
_calculateWidgetHeight(shrinkOffset)),
child: flexibleImage,
),
)),
Padding(
padding: EdgeInsets.only(top: _textPadding(shrinkOffset)),
child: Text(
flexibleTitle,
textScaleFactor: _scaleFactor(shrinkOffset),
style: TextStyle(
fontSize: _defaultTextHeight,
color: Colors.white
.withOpacity(_scaleFactor(shrinkOffset)), height: 1),
),
)
],
),
),
),
button: true,
),
);
}
#override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
final Widget appBar = FlexibleSpaceBar.createSettings(
minExtent: minExtent,
maxExtent: maxExtent,
currentExtent: max(minExtent, maxExtent - shrinkOffset),
toolbarOpacity: 1,
child: AppBar(
actions: <Widget>[rightButton == null ? Container() : rightButton],
leading: leftButton == null ? Container() : leftButton,
backgroundColor: Colors.blue,
automaticallyImplyLeading: false,
title: title != null
? Text(
title,
style: TextStyle(
color: flexToTop
? Colors.white.withOpacity(_scaleFactor(shrinkOffset))
: Colors.white),
)
: null,
flexibleSpace: Padding(
padding: EdgeInsets.only(top: _widgetPadding(shrinkOffset)),
child: _builtContent(context, shrinkOffset),
),
centerTitle: true,
toolbarOpacity: 1,
bottomOpacity: 1.0),
);
return appBar;
}
#override
double get maxExtent => expandedHeight + safeAreaPadding;
#override
double get minExtent => title == null
? _defaultAppBarHeight + safeAreaPadding
: flexToTop
? _defaultAppBarHeight + safeAreaPadding
: _defaultAppBarHeight + safeAreaPadding + flexibleSize;
#override
bool shouldRebuild(AppBarSliverHeader old) {
if (old.flexibleImage != flexibleImage) {
return true;
}
return false;
}
}
and here is usage
Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
pinned: true,
floating: true,
delegate: AppBarSliverHeader(
expandedHeight: 250,
safeAreaPadding: MediaQuery.of(context).padding.top,
title: 'New Contact',
flexibleImage: Image.asset('assets/images/avatar.png'),
flexibleTitle: 'Add Image',
flexiblePadding: 6,
flexibleSize: 50,
flexToTop: true,
onTap: () {
print('hello');
},
leftButton: IconButton(
icon: Text('Cancel'),
iconSize: 60,
padding: EdgeInsets.zero,
onPressed: () {},
),
rightButton: IconButton(
icon: Text('Done'),
iconSize: 60,
padding: EdgeInsets.zero,
onPressed: () {},
)),
),
SliverList(
delegate: SliverChildListDelegate([
TextField(),
]),
)
],
),
);
There are some things which took me by surprise as well. First is text size. It seems like text size is not an actual text size so I've added _unknownTextValue there for compensation. Also even if text size is set to 0 then the Text widget has still 1px size so I've compensated that in commented code. Another thing is I wanted to use CircularAvatar for the image but apparently the CircularAvatar widget has built in animation when changing the size which interfere with app bar animation so I've built custom avatar.
UPDATE: To make actual text height same as font size, I have added height property 1 to TextStyle. It seems to work however there is still occasional overflow on the textfield of up to 1px so I've kept _unknownTextValue at 1px
As I said I'm not sliver expert so there might be a better solutions out there so I would suggest you to wait for other answers
NOTE: I only tested it on 2 iOS devices so you should test further to use it
With Title
Without Title
With Title and flexToTop activated

Flutter: Drag Draggable Stack item inside a Draggable Stack Item

I have a Draggable on a DragTarget as part of a Stack. Inside is another Stack with Draggables, again on DragTargets and so on... (Stack over Stack over Stack etc.).
The Draggable is a Positioned with a Listener telling where to be placed.
homeView.dart
body: Stack(children: [
DraggableWidget(parentKey, Offset(0, 0)),
]),
draggableWidget.dart
class DraggableWidget extends StatefulWidget {
final Key itemKey;
final Offset itemPosition;
DraggableWidget(this.itemKey, this.itemPosition);
#override
_DraggableWidgetState createState() => _DraggableWidgetState();
}
class _DraggableWidgetState extends State<DraggableWidget> {
Offset tempDelta = Offset(0, 0);
Window<List<Key>> item;
List<DraggableWidget> childList = [];
Map<Key, Window<List>> structureMap;
initState() {
super.initState();
}
#override
Widget build(BuildContext context) {
structureMap = Provider.of<Data>(context).structureMap;
if (structureMap[widget.itemKey] != null) {
structureMap[widget.itemKey].childKeys.forEach(
(k) => childList.add(
DraggableWidget(k, item.position),
),
);
} else {
structureMap[widget.itemKey] = Window<List<Key>>(
title: 'App',
key: widget.itemKey,
size: Size(MediaQuery.of(context).size.width,
MediaQuery.of(context).size.height),
position: Offset(0, 0),
color: Colors.blue,
childKeys: []);
}
item = Provider.of<Data>(context).structureMap[widget.itemKey];
return Positioned(
top: item.position.dx,
left: item.position.dy,
child: DragTarget(
builder:
(buildContext, List<Window<List<Key>>> candidateData, rejectData) {
return Listener(
onPointerDown: (PointerDownEvent event) {},
onPointerUp: (PointerUpEvent event) {
setState(() {
item.position = Offset(item.position.dx + tempDelta.dx,
item.position.dy + tempDelta.dy);
tempDelta = Offset(0, 0);
});
},
onPointerMove: (PointerMoveEvent event) {
tempDelta = Offset((event.delta.dy + tempDelta.dx),
(event.delta.dx + tempDelta.dy));
},
child: Draggable(
childWhenDragging: Container(),
feedback: Container(
color: item.color,
height: item.size.height,
width: item.size.width,
),
child: Column(children: [
Text(item.title),
Container(
color: item.color,
height: item.size.height,
width: item.size.width,
child: ItemStackBuilder(widget.itemKey, item.position),
),
]),
data: item),
);
},
),
);
}
}
itemStackBuilder.dart
class ItemStackBuilder extends StatelessWidget {
final Key itemKey;
final Offset itemPosition;
ItemStackBuilder(this.itemKey, this.itemPosition);
#override
Widget build(BuildContext context) {
Map<Key, Window<List<Key>>> structureMap =
Provider.of<Data>(context).structureMap;
if (structureMap[itemKey] == null) {
structureMap[itemKey] = Window(size: Size(20, 20), childKeys: []);
}
return Stack(overflow: Overflow.visible, children: [
...stackItems(context),
Container(
height: structureMap[itemKey].size.height,
width: structureMap[itemKey].size.width,
color: Colors.transparent),
]);
}
List<Widget> stackItems(BuildContext context) {
List<Key> childKeyList =
Provider.of<Data>(context).structureMap[itemKey].childKeys;
var stackItemDraggable;
List<Widget> stackItemsList = [];
if (childKeyList == null || childKeyList.length < 1) {
stackItemsList = [Container()];
} else {
for (int i = 0; i < childKeyList.length; i++) {
stackItemDraggable = DraggableWidget(childKeyList[i], itemPosition);
stackItemsList.add(stackItemDraggable);
}
}
return stackItemsList;
}
}
When I want to move the Draggable item on top, the underlying Stack moves.
I tried it with a Listener widget and was able to detect all RenderBoxes inside the Stack.
But how can I select the specific Draggable and/or disable all the other layers? Is it a better idea to forget about Draggables and do it all with Positioned and GestureDetector?
Ok, it was my mistake not of the framework:
on itemStackBuilder.dart I used an additional Container to size the Stack. I was not able to recognise, because color was transparent:
Container(
height: structureMap[itemKey].size.height,
width: structureMap[itemKey].size.width,
color: Colors.transparent),
]);
}
After deleting this part, all works fine for now.

Flutter: inverted ClipOval

I am new to Flutter and I am trying to write a library to allow users to pan/zoom their profile picture.
In order to make it visual, I would like to stack their picture with an "inverted" ClipOval, to show the boundaries.
So far, this is the result I obtain:
This shows the boundaries but this is not user friendly and I would like to "invert" the ClipOval so that the center of the clip is "clear" and the outside is grayed out (something like a mask).
Is there any way to achieve this?
Here is the code I have so far (part of it comes from flutter_zoomable_image):
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ImagePanner extends StatefulWidget {
ImagePanner(this.image, {Key key}) : super(key: key);
/// The image to be panned
final ImageProvider image;
#override
_ImagePannerState createState() => new _ImagePannerState();
}
class _ImagePannerState extends State<ImagePanner> {
ImageStream _imageStream;
ui.Image _image;
double _zoom = 1.0;
Offset _offset = Offset.zero;
double _scale = 16.0;
#override
void didChangeDependencies() {
_resolveImage();
super.didChangeDependencies();
}
#override
void reassemble() {
_resolveImage();
super.reassemble();
}
#override
Widget build(BuildContext context) {
if (_image == null) {
return new Container();
}
return new Container(
width: double.INFINITY,
color: Colors.amber,
child: new Padding(
padding: new EdgeInsets.all(50.0),
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new AspectRatio(
aspectRatio: 1.0,
child: new Stack(
children: [
_child(),
new Opacity(
opacity: 0.5,
child: new ClipOval(
child: new Container(
color: Colors.black,
),
),
),
],
),
),
],
)),
);
}
Widget _child() {
Widget bloated = new CustomPaint(
child: new Container(),
painter: new _ImagePainter(
image: _image,
offset: _offset,
zoom: _zoom / _scale,
),
);
bloated = new Stack(
children: [
new Container(
),
bloated
],
);
return new Transform(
transform: new Matrix4.diagonal3Values(_scale, _scale, _scale),
child: bloated);
}
void _resolveImage() {
_imageStream = widget.image.resolve(createLocalImageConfiguration(context));
_imageStream.addListener(_handleImageLoaded);
}
void _handleImageLoaded(ImageInfo info, bool synchronousCall) {
print("image loaded: $info $synchronousCall");
setState(() {
_image = info.image;
});
}
}
class _ImagePainter extends CustomPainter {
const _ImagePainter({this.image, this.offset, this.zoom});
final ui.Image image;
final Offset offset;
final double zoom;
#override
void paint(Canvas canvas, Size size) {
paintImage(canvas: canvas, rect: offset & (size * zoom), image: image);
}
#override
bool shouldRepaint(_ImagePainter old) {
return old.image != image || old.offset != offset || old.zoom != zoom;
}
}
The outcome I would like to obtain is the following so that users will directly see the boundaries and will be able to center, pan, zoom their profile picture INSIDE the oval.
(I made this via Photoshop, since I don't know how to achieve this with Flutter)
Many thanks for your help.
There's a couple other ways you could do this - you could simply draw an overlay in a CustomCanvas using a path that has a circle & rectangle, as all you really need is a rectangular semi-transparent rectangle with a hole in it. But you can also use a CustomClipper which gives you more flexibility in the future without having to draw stuff manually.
void main() {
int i = 0;
runApp(new MaterialApp(
home: new SafeArea(
child: new Stack(
children: <Widget>[
new GestureDetector(
onTap: () {
print("Tapped! ${i++}");
},
child: new Container(
color: Colors.white,
child: new Center(
child: new Container(
width: 400.0,
height: 300.0,
color: Colors.red.shade100,
),
),
),
),
new IgnorePointer(
child: new ClipPath(
clipper: new InvertedCircleClipper(),
child: new Container(
color: new Color.fromRGBO(0, 0, 0, 0.5),
),
),
)
],
),
),
));
}
class InvertedCircleClipper extends CustomClipper<Path> {
#override
Path getClip(Size size) {
return new Path()
..addOval(new Rect.fromCircle(
center: new Offset(size.width / 2, size.height / 2),
radius: size.width * 0.45))
..addRect(new Rect.fromLTWH(0.0, 0.0, size.width, size.height))
..fillType = PathFillType.evenOdd;
}
#override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
IgnorePointer is needed, or events won't be propagated through the semi-transparent part (assuming you need touch events).
How this works is that the Path used by clipPath is a circle in the middle (you need to adjust the size manually) with a rectangle taking up the entire size. fillType = PathFillType.evenOdd is important because it tells the path's fill should be between the circle and the rectangle.
If you wanted to use a customPainter instead, the path would be the same and you'd just draw it instead.
This all results in this: