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:
Related
I am currently working on a layout that displays a Positioned widget on the entire screen.
It's positioning itself close to the detected barcode, Look at the image below for an example.
But when the barcode moves to close the the left edge of the screen, the UI elements are drawn partially offscreen. Is there a way I can fix this without having to calculate when I am going out of bounds each frame?
Here is the code that I use to set this up:
Widget _buildImage() {
return Container(
constraints: const BoxConstraints.expand(),
child: _controller == null
? const Center(
child: Text(
'Initializing Camera...',
style: TextStyle(
color: Colors.green,
fontSize: 30.0,
),
),
)
: Stack(
fit: StackFit.expand,
children: <Widget>[
CameraPreview(_controller!),
_buildResults(),
if (_scanResults.isNotEmpty)
_buildUIElements()
],
),
);
}
Widget _buildUIElements() {
Barcode barcode = _scanResults[0];
final Size imageSize = Size(
_controller!.value.previewSize!.height,
_controller!.value.previewSize!.width,
);
var boundingBox = barcode.boundingBox!;
var rect = scaleRect(rect: boundingBox, imageSize: imageSize, widgetSize: MediaQuery.of(context).size);
return AnimatedPositioned(
top: rect.bottom,
left: rect.left,
child: Card(
child: Text('This is an amaizing product'),
),
duration: const Duration(milliseconds: 500),
);
}
Maybe there is a better way to achieve this?
Don't mind the excessive use of ! still learning the whole null-safety thing :)
EDIT 1:
As suggested by pskink I have looked at how the tooltips in flutter work and made use of the SingleChildLayoutDelegate in combination with a CustomSingleChildLayout and this works perfectly for tracking the position but now there is no option to animate this.
My delegate class is as follows:
class CustomSingleChildDelegate extends SingleChildLayoutDelegate {
CustomSingleChildDelegate ({
required this.target,
required this.verticalOffset,
required this.preferBelow,
});
final Offset target;
final double verticalOffset;
final bool preferBelow;
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();
#override
Offset getPositionForChild(Size size, Size childSize) {
return positionDependentBox(
size: size,
childSize: childSize,
target: target,
verticalOffset: verticalOffset,
preferBelow: preferBelow,
);
}
#override
bool shouldRelayout(CustomSingleChildDelegate oldDelegate) {
return target != oldDelegate.target
|| verticalOffset != oldDelegate.verticalOffset
|| preferBelow != oldDelegate.preferBelow;
}
}
And then updated my builder function with:
return CustomSingleChildLayout(
delegate: CustomSingleChildDelegate (target: rect.bottomCenter, verticalOffset: 20, preferBelow: true),
child: Card(
child: Text('This is an amaizing product'),
),
)
Having the AnimatedPositioned as child of the layout causes an exception.
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);
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
any way to add indicator to BottomNavigatorBarItem like this image?
This package should be able to help you achieve it.
You can use a TabBar instead of a BottomNavigationBar using a custom decoration:
class TopIndicator extends Decoration {
#override
BoxPainter createBoxPainter([VoidCallback? onChanged]) {
return _TopIndicatorBox();
}
}
class _TopIndicatorBox extends BoxPainter {
#override
void paint(Canvas canvas, Offset offset, ImageConfiguration cfg) {
Paint _paint = Paint()
..color = Colors.lightblue
..strokeWidth = 5
..isAntiAlias = true;
canvas.drawLine(offset, Offset(cfg.size!.width + offset.dx, 0), _paint);
}
}
Then pass the decoration to the TapBar using TapBar(indicator: TopIndicator ...).
To use the TabBar as the Scaffold.bottomNavigationBar, you will most likely want to wrap it in a Material to apply a background color:
Scaffold(
bottomNavigationBar: Material(
color: Colors.white,
child: TabBar(
indicator: TopIndicator(),
tabs: const <Widget>[
Tab(icon: Icon(Icons.home_outlined), text: 'Reward'),
...
],
),
),
...
)
Thanks Ara Kurghinyan for the original idea.
I've had the same problem and all the packages I found seem to require raw IconData, which makes it impossible to use widget functionality like number badges (e.g. the number of unread chat messages).
I came up with my own little solution; first, I made a widget to display the actual indicators:
class TabIndicators extends StatelessWidget {
final int _numTabs;
final int _activeIdx;
final Color _activeColor;
final Color _inactiveColor;
final double _padding;
final double _height;
const TabIndicators({
required int numTabs,
required int activeIdx,
required Color activeColor,
required double padding,
required double height,
Color inactiveColor = const Color(0x00FFFFFF),
Key? key }) :
_numTabs = numTabs,
_activeIdx = activeIdx,
_activeColor = activeColor,
_inactiveColor = inactiveColor,
_padding = padding,
_height = height,
super(key: key);
#override
Widget build(BuildContext context) {
final elements = <Widget>[];
for(var i = 0; i < _numTabs; ++i) {
elements.add(
Expanded(child:
Padding(
padding: EdgeInsets.symmetric(horizontal: _padding),
child: Container(color: i == _activeIdx ? _activeColor : _inactiveColor),
)
)
);
}
return
SizedBox(
height: _height,
child: Row(
mainAxisSize: MainAxisSize.max,
children: elements,
),
);
}
}
This can be prepended to the actual BottomNavigationBar like this:
bottomNavigationBuilder: (context, tabsRouter) {
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TabIndicators(
activeIdx: tabsRouter.activeIndex,
activeColor: Theme.of(context).primaryColor,
numTabs: 4,
padding: 25,
height: 4,
),
BottomNavigationBar(...
This works perfectly well for me, but to make it look decent, you'd have to set the BottomNavigationBar's elevation to zero, otherwise there's still a faint horizontal line between the indicators and the icons.
A Stack contains MyWidget inside of a Positioned.
Stack(
overflow: Overflow.visible,
children: [
Positioned(
top: 0.0,
left: 0.0,
child: MyWidget(),
)],
);
Since overflow is Overflow.visible and MyWidget is larger than the Stack, it displays outside of the Stack, which is what I want.
However, I can't tap in the area of MyWidget which is outside of the Stack area. It simply ignores the tap there.
How can I make sure MyWidget accepts gestures there?
This behavior occurs because the stack checks whether the pointer is inside its bounds before checking whether a child got hit:
Class: RenderBox (which RenderStack extends)
bool hitTest(BoxHitTestResult result, { #required Offset position }) {
...
if (_size.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
My workaround is deleting the
if (_size.contains(position))
check.
Unfortunately, this is not possible without copying code from the framework.
Here is what I did:
Copied the Stack class and named it Stack2
Copied RenderStack and named it RenderStack2
Made Stack2 reference RenderStack2
Added the hitTest method from above without the _size.contains check
Copied Positioned and named it Positioned2 and made it reference Stack2 as its generic parameter
Used Stack2 and Positioned2 in my code
This solution is by no means optimal, but it achieves the desired behavior.
I had a similar issue. Basically since the stack's children don't use the fully overflown box size for their hit testing, i used a nested stack and an arbitrary big height so that i can capture the clicks of the nested stack's overflown boxes. Not sure if it can work for you but here goes nothing :)
So in your example maybe you could try something like that
Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0.0,
left: 0.0,
height : 500.0 // biggest possible child size or just very big
child: Stack(
children: [MyWidget()]
),
)],
);
You can consider using inheritance to copy the hitTest method to break the hit rule, example
class Stack2 extends Stack {
Stack2({
Key key,
AlignmentGeometry alignment = AlignmentDirectional.topStart,
TextDirection textDirection,
StackFit fit = StackFit.loose,
Overflow overflow = Overflow.clip,
List<Widget> children = const <Widget>[],
}) : super(
key: key,
alignment: alignment,
textDirection: textDirection,
fit: fit,
overflow: overflow,
children: children,
);
#override
RenderStack createRenderObject(BuildContext context) {
return RenderStack2(
alignment: alignment,
textDirection: textDirection ?? Directionality.of(context),
fit: fit,
overflow: overflow,
);
}
}
class RenderStack2 extends RenderStack {
RenderStack2({
List<RenderBox> children,
AlignmentGeometry alignment = AlignmentDirectional.topStart,
TextDirection textDirection,
StackFit fit = StackFit.loose,
Overflow overflow = Overflow.clip,
}) : super(
children: children,
alignment: alignment,
textDirection: textDirection,
fit: fit,
overflow: overflow,
);
#override
bool hitTest(BoxHitTestResult result, {Offset position}) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
return false;
}
}
Ok, I did a workaround about this, basically I added a GestureDetector on the parent and implemented the onTapDown.
Also you have to keep track your Widget using GlobalKey to get the current position.
When the Tap at the parent level is detected check if the tap position is inside your widget.
The code below:
final GlobalKey key = new GlobalKey();
void onTapDown(BuildContext context, TapDownDetails details) {
final RenderBox box = context.findRenderObject();
final Offset localOffset = box.globalToLocal(details.globalPosition);
final RenderBox containerBox = key.currentContext.findRenderObject();
final Offset containerOffset = containerBox.localToGlobal(localOffset);
final onTap = containerBox.paintBounds.contains(containerOffset);
if (onTap){
print("DO YOUR STUFF...");
}
}
#override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (TapDownDetails details) => onTapDown(context, details),
child: Container(
color: Colors.red,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 200.0,
height: 400.0,
child: Container(
color: Colors.black,
child: Stack(
overflow: Overflow.visible,
children: [
Positioned(
top: 0.0, left: 0.0,
child: Container(
key: key,
width: 500.0,
height: 200.0,
color: Colors.blue,
),
),
],
),
),
),
),
),
);
}
This limitation can be worked around by using an OverlayEntry widget as the Stack's parent (since OverlayEntry fills up the entire screen all children are also hit tested). Here is a proof of concept solution on DartPad.
Create a custom widget that returns a Future:
Widget build(BuildContext context) {
Future(showOverlay);
return Container();
}
This future should then remove any previous instance of OverlayEntry and insert the Stack with your custom widgets:
void showOverlay() {
hideOverlay();
RenderBox? renderBox = context.findAncestorRenderObjectOfType<RenderBox>();
var parentSize = renderBox!.size;
var parentPosition = renderBox.localToGlobal(Offset.zero);
overlay = _overlayEntryBuilder(parentPosition, parentSize);
Overlay.of(context)!.insert(overlay!);
}
void hideOverlay() {
overlay?.remove();
}
Use a builder function to generate the Stack:
OverlayEntry _overlayEntryBuilder(Offset parentPosition, Size parentSize) {
return OverlayEntry(
maintainState: false,
builder: (context) {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: parentPosition.dx + parentSize.width,
top: parentPosition.dy + parentSize.height,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {},
child: Container(),
),
),
),
],
);
},
);
}
Column is another Stack
key point:
verticalDirection is up.
transform down the top widget.
Below is my code, you can copy and test:
Column(
verticalDirection: VerticalDirection.up,
children: [
Container(
width: 200,
height: 100,
color: Colors.red,
),
Transform.translate(
offset: const Offset(0, 30),
child: GestureDetector(
onTap: () {
print('tap orange view');
},
child: Container(
width: 60,
height: 60,
color: Colors.orange,
),
),
),
],
),
I write a container to resolve this problem, which not implements beautifully, but can be used and did code encapsulation for easily to use.
Here is the implement:
import 'package:flutter/widgets.dart';
/// Creates a widget that can check its' overflow children's hitTest
///
/// [overflowKeys] is must, and there should be used on overflow widget's outermost widget those' sizes cover the overflow child, because it will [hitTest] its' children, but not [hitTest] its' parents. And i cannot found a way to check RenderBox's parent in flutter.
///
/// The [OverflowWithHitTest]'s size must contains the overflow widgets, so you can use it as outer as possible.
///
/// This will not reduce rendering performance, because it only overcheck the given widgets marked by [overflowKeys].
///
/// Demo:
///
/// class MyPage extends State<UserCenterPage> {
///
/// var overflowKeys = <GlobalKey>[GlobalKey()];
///
/// Widget build(BuildContext context) {
/// return Scaffold(
/// body: OverflowWithHitTest(
///
/// overflowKeys: overflowKeys,
///
/// child: Container(
/// height: 50,
/// child: UnconstrainedBox(
/// child: Container(
/// width: 200,
/// height: 50,
/// color: Colors.red,
/// child: OverflowBox(
/// alignment: Alignment.topLeft,
/// minWidth: 100,
/// maxWidth: 200,
/// minHeight: 100,
/// maxHeight: 200,
/// child: GestureDetector(
/// key: overflowKeys[0],
/// behavior: HitTestBehavior.translucent,
/// onTap: () {
/// print('==== onTap;');
/// },
/// child: Container(
/// color: Colors.blue,
/// height: 200,
/// child: Text('aaaa'),
/// ),
/// ),
/// ),
/// ),
/// ),
/// ),
/// ),
/// );
/// }
/// }
///
///
class OverflowWithHitTest extends SingleChildRenderObjectWidget {
const OverflowWithHitTest({
required this.overflowKeys,
Widget? child,
Key? key,
}) : super(key: key, child: child);
final List<GlobalKey> overflowKeys;
#override
_OverflowWithHitTestBox createRenderObject(BuildContext context) {
return _OverflowWithHitTestBox(overflowKeys: overflowKeys);
}
#override
void updateRenderObject(
BuildContext context, _OverflowWithHitTestBox renderObject) {
renderObject.overflowKeys = overflowKeys;
}
#override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(
DiagnosticsProperty<List<GlobalKey>>('overflowKeys', overflowKeys));
}
}
class _OverflowWithHitTestBox extends RenderProxyBoxWithHitTestBehavior {
_OverflowWithHitTestBox({required List<GlobalKey> overflowKeys})
: _overflowKeys = overflowKeys,
super(behavior: HitTestBehavior.translucent);
/// Global keys of overflow children
List<GlobalKey> get overflowKeys => _overflowKeys;
List<GlobalKey> _overflowKeys;
set overflowKeys(List<GlobalKey> value) {
var changed = false;
if (value.length != _overflowKeys.length) {
changed = true;
} else {
for (var ind = 0; ind < value.length; ind++) {
if (value[ind] != _overflowKeys[ind]) {
changed = true;
}
}
}
if (!changed) {
return;
}
_overflowKeys = value;
markNeedsPaint();
}
#override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (hitTestOverflowChildren(result, position: position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
bool hitTarget = false;
if (size.contains(position)) {
hitTarget =
hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
bool hitTestOverflowChildren(BoxHitTestResult result,
{required Offset position}) {
if (overflowKeys.length == 0) {
return false;
}
var hitGlobalPosition = this.localToGlobal(position);
for (var child in overflowKeys) {
if (child.currentContext == null) {
continue;
}
var renderObj = child.currentContext!.findRenderObject();
if (renderObj == null || renderObj is! RenderBox) {
continue;
}
var localPosition = renderObj.globalToLocal(hitGlobalPosition);
if (renderObj.hitTest(result, position: localPosition)) {
return true;
}
}
return false;
}
}