Add indicator to BottomNavigationBar in flutter - flutter

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.

Related

Create Dynamically Sized Squares

I'm attempting to create a GitHub style heat map inside a Card and am struggling with the UI. The challenge is making the heat map dynamically expand to fit the Card it sits in based on the device's screen size.
Here is an example screenshot.
The code to create the screenshot is below.
Essentially the code,
creates a column that starts with two lines of text
then inserts a Row of Columns that consist of squares
I'm not sure if I should focus on making the individual boxes expand, the columns that the individual boxes sit in, or both. All my experiments end in unbound errors. I'm not sure where/how to add the constraints.
I also assume I'll need the boxes to be wrapped in AspectRatio() to keep the 1:1 ratio and be a square.
(I've removed some of the the more verbose business logic in my actual code for simplicity.)
class ProfileView extends StatelessWidget {
const ProfileView({Key? key}) : super(key: key);
List<Widget> _heatMapColumnList() {
final _columns = <Widget>[];
final _startDate = DateTime.now().subtract(const Duration(days: 365));
final _endDate = DateTime.now();
final _dateDifference = _endDate.difference(_startDate).inDays;
for (var index = 0 - (_startDate.weekday % 7);
index <= _endDate.difference(_startDate).inDays;
index += 7) {
//helper to change date by index
final _firstDay = DateUtility.changeDay(_startDate, index);
_columns.add(
HeatMapColumn(
startDate: _firstDay,
endDate: index <= _dateDifference - 7
? DateUtility.changeDay(_startDate, index + 6)
: _endDate,
numDays: min(_endDate.difference(_firstDay).inDays + 1, 7),
),
);
}
return _columns;
}
#override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('Some Title Text'),
const Text('More SubTitle Text'),
const SizedBox(height: 10),
Row(
children: <Widget>[
..._heatMapColumnList(),
],
...
...
class HeatMapColumn extends StatelessWidget {
HeatMapColumn({
super.key,
required this.startDate,
required this.endDate,
required this.numDays,
}) : dayContainers = List.generate(
numDays,
(i) => HeatMapBox(
date: DateUtility.changeDay(startDate, 1),
),
),
emptySpace = (numDays != 7)
? List.generate(
7 - numDays,
(i) => const HeatMapBox(
date: null,
),
)
: [];
final List<Widget> dayContainers;
final List<Widget> emptySpace;
final DateTime startDate;
final DateTime endDate;
final int numDays;
#override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: <Widget>[
...dayContainers,
...emptySpace,
],
...
// !!!THIS IS THE BOX I WANT TO DYNAMICALLY RESIZE!!!
class HeatMapBox extends StatelessWidget {
const HeatMapBox({
required this.date,
this.color,
super.key,
});
final DateTime? date;
final Color? color;
#override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(1),
child: SizedBox(
child: Container(
// ???HOW DO I AVOID THIS EXPLICIT NUMERIC CONTAINER SIZE???
height: 3,
width: 3,
decoration: const BoxDecoration(
color: Colors.black12,
),
),
),
);
}
}
I would add a comment but I do not have enough reputation so sorry if this is not the answer you are looking for
You could use something like this
double width = MediaQuery.of(context).size.width; // gives width of device screen
double height = MediaQuery.of(context).size.height; // gives height of device screen
// if the card has padding
double cardLeftPadding = a double;
double cardRightPadding = a double;
width -= (cardLeftPadding + cardRightPadding);
Container(
// ???HOW DO I AVOID THIS EXPLICIT NUMERIC CONTAINER SIZE???
height: 3,
width: width,
decoration: const BoxDecoration(
color: Colors.black12,
),),
I believe something like this will allow you to fit your heat map to the full length of your card

Constrainting layout to not go out of bounds with Positioned widget

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.

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 TextField - how to shrink the font if the text entered overflows

I have a TextField (not a Text) widget that must remain on one line. I want to reduce it's font size if the text entered is too large for the TextField box, ie shrink it if it overflows. How can I do this?
I have written some code like this in a stateful component
if (textLength < 32) {
newAutoTextVM.fontSize = 35.0;
} else if (textLength < 42) {
newAutoTextVM.fontSize = 25.0;
In the view
fontSize: 25.0,
but it isn't very intelligent, it doesn't cope with resizing, also, because the font size isn't monospaced (courier etc), different characters take up different amounts of space.
Use a TextPainter to calculate the width of your text. Use a GlobalKey to get the size of your widget (A LayoutBuilder might be better to handle screen rotation).
import 'package:flutter/material.dart';
main() => runApp(MaterialApp(home: Home()));
class Home extends StatefulWidget {
#override
_HomeState createState() => _HomeState();
}
const textFieldPadding = EdgeInsets.all(8.0);
const textFieldTextStyle = TextStyle(fontSize: 30.0);
class _HomeState extends State<Home> {
final TextEditingController _controller = TextEditingController();
final GlobalKey _textFieldKey = GlobalKey();
double _textWidth = 0.0;
double _fontSize = textFieldTextStyle.fontSize;
#override
void initState() {
super.initState();
_controller.addListener(_onTextChanged);
}
void _onTextChanged() {
// substract text field padding to get available space
final inputWidth = _textFieldKey.currentContext.size.width - textFieldPadding.horizontal;
// calculate width of text using text painter
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: _controller.text,
style: textFieldTextStyle,
),
);
textPainter.layout();
var textWidth = textPainter.width;
var fontSize = textFieldTextStyle.fontSize;
// not really efficient and doesn't find the perfect size, but you got all you need!
while (textWidth > inputWidth && fontSize > 1.0) {
fontSize -= 0.5;
textPainter.text = TextSpan(
text: _controller.text,
style: textFieldTextStyle.copyWith(fontSize: fontSize),
);
textPainter.layout();
textWidth = textPainter.width;
}
setState(() {
_textWidth = textPainter.width;
_fontSize = fontSize;
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Autosize TextField'),
),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextField(
key: _textFieldKey,
controller: _controller,
decoration: InputDecoration(
border: InputBorder.none,
fillColor: Colors.orange,
filled: true,
contentPadding: textFieldPadding,
),
style: textFieldTextStyle.copyWith(fontSize: _fontSize),
),
Text('Text width:'),
Container(
padding: textFieldPadding,
color: Colors.orange,
child: Row(
children: <Widget>[
Container(width: _textWidth, height: 20.0, color: Colors.blue),
],
),
)
],
),
),
);
}
}
I have searched through the docs and found a couple of solutions that could come at your help:
L̶o̶o̶k̶ ̶a̶t̶ ̶t̶h̶e̶ ̶o̶f̶f̶i̶c̶i̶a̶l̶ ̶d̶o̶c̶s̶[̶1̶]̶,̶ ̶i̶n̶ ̶p̶a̶r̶t̶i̶c̶u̶l̶a̶r̶e̶ ̶a̶t̶ ̶t̶h̶e̶s̶e̶ ̶p̶r̶o̶p̶e̶r̶t̶i̶e̶s̶:̶ ̶ ̶m̶a̶x̶L̶i̶n̶e̶s̶,̶ ̶o̶v̶e̶r̶f̶l̶o̶w̶ ̶a̶n̶d̶ ̶s̶o̶f̶t̶W̶r̶a̶p̶ (These are TextBox properties, not TextFields)
Have a look at this thread where they suggest to wrap the TextBox/TextFeld with a Flexible Widget
Depending on the rest of your code one of these solutions could be better, try tweaking around.
Hope it helps.

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: