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
I want to create a scrollable card stack. I want the back one to disappear and the front one to disappear when I slide what is seen in the photo, how can I do that?
The below code should work.
On tapping/clicking anywhere the cards should change position with the topmost card going to the back.
Changing _spacing's value changes the relative horizontal and vertical gaps among the cards in the stack.
Note that the members of the _colors list are in reverse order. This is because, with Stack, widgets are displayed on top of each other based on the last widgets that were added to the children list. This also explains why the call back to setState makes the last member of the _colors list to become the first member instead.
class StackSwipe extends StatefulWidget {
const StackSwipe({Key? key}) : super(key: key);
#override
State<StackSwipe> createState() => _StackSwipeState();
}
class _StackSwipeState extends State<StackSwipe> {
final double _spacing = 16.0;
late double _baseWidth;
final _colors = [
Colors.greenAccent.shade100,
Colors.pink.shade100,
Colors.amber.shade100,
];
#override
Widget build(BuildContext context) {
var deviceWidth = MediaQuery.of(context).size.width;
_baseWidth = deviceWidth > 544 ? 512.0 : deviceWidth - 32;
return Padding(
padding: const EdgeInsets.all(16),
child: GestureDetector(
child: Stack(children: [
..._colors.asMap().entries.map(
(entry) => Positioned(
top: _spacing * entry.key,
left: _spacing * (_colors.length - (entry.key + 1)),
child: Container(
height: 200,
width: _baseWidth -
(_spacing * (_colors.length - (entry.key + 1)) * 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: entry.value,
),
),
),
)
]),
onTap: () => setState(() => _colors.insert(0, _colors.removeLast())),
),
);
}
}
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.
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;
}
}
This Shows all 3 cases discussed in this other post with a simple example.
How to use the constraints and sizes of other widgets during the build phase
NOTE:
I know that this is weird
I know that if used improperly this could create layouts that are never drawn
Just Reading Constraints is not enough because sometimes the constraints don't exist (like in this particular case)
GOAL:
Get what is drawn on screen to stabilize after 0 ReBuilds (or 1 build) instead of 2 ReBuilds
CURRENT PROCESS:
Build 1
Build 2
Build 3
when ("automaticReBuilding " == true) => the system automatically rebuilds itself depending on the quantity of dependancies (this is determined by you)
[the fact that automatic rebuilding runs the build function multiple times is what creates the stutter problem I have referred to now and in previous posts]
when ("automaticReBuilding" == false) => the system waits for you to rebuild things manually
//--------------------------------------------------CODE START
import 'package:flutter/material.dart';
import 'dart:async';
//Desired Behavior on FIRST build (It should not take 3)
//CASE 1 (parent uses child size) : eye.width = vane.width * 10
//CASE 2 (child uses parent size) : pupil.width = iris.width / 2
//CASE 3: (child uses sibling size) : iris.width = vane.width * 5
//Desired Sizes (can be read from Render Tree in Flutter Inspector) [in original config of 4 letters]
//vane = 30
//pupil = 75
//iris = 150
//eye = 300
//NOTE: that vane width (aka size) is not determined until we see what is inside of it
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return new StateFull();
}
}
class StateFull extends StatefulWidget {
#override
_StateFullState createState() => new _StateFullState();
}
var vaneKey = new GlobalKey();
var vaneWidth;
var irisKey = new GlobalKey();
var irisWidth;
class _StateFullState extends State<StateFull> {
//NOTE: change this to either run the rebuild in one shot or slowly see the progression
bool automaticReBuilding = false;
//NOTE: this starts here because the first build method isn't technically a rebuild
int timesReBuilt = -1;
//NOTE: this is set MANUALLY given the dependencies between your widgets
//In this particular case C relies on B which relies on A
//so (first) I get the size of A, (second) I use the size of A to get B, (third) i use the size of B to get C
//which comes down to 3 rebuilds
int requiredBuildsPerChange = 3;
int timesBuilt = 0;
rebuild(){
setState(() {
});
}
rebuildAsync() async{
await Future.delayed(Duration.zero);
setState(() {
});
}
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
timesReBuilt++;
if(automaticReBuilding){
timesBuilt++;
print("build #" + timesBuilt.toString());
if(timesBuilt < requiredBuildsPerChange)
rebuildAsync();
else
timesBuilt = 0;
}
var complexWidget = complexRelationshipWidget();
return new MaterialApp(
title: '3 Cases Test',
home: new Scaffold(
backgroundColor: Colors.brown,
body: new Stack(
children: <Widget>[
new Align(
alignment: Alignment.center,
child: complexWidget,
),
new Container(
padding: EdgeInsets.all(16.0),
alignment: Alignment.bottomRight,
child: new RaisedButton(
onPressed: () => (automaticReBuilding == false) ? rebuild() : null,
child: new Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.update),
new Text("Manual ReBuilds\nOR\nAutomatic Frame Stutter\n$timesReBuilt", textAlign: TextAlign.center,),
],
),
),
),
],
)
),
);
}
Container complexRelationshipWidget() {
vaneWidth = vaneKey?.currentContext?.findRenderObject()?.semanticBounds?.size?.width;
irisWidth = irisKey?.currentContext?.findRenderObject()?.semanticBounds?.size?.width;
return new Container( //-----EYE-----
decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.white),
width: vaneWidth == null ? null : vaneWidth * 10,
alignment: Alignment.center,
child: new Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Container( //-----VANE-----
key: vaneKey,
color: Colors.red,
child: new Text("vane"),
),
new Container( //-----IRIS-----
key: irisKey,
decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.blue),
width: vaneWidth == null ? null : vaneWidth * 5,
alignment: Alignment.center,
child: new Container( //-----PUPIL
decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.black),
width: irisWidth == null ? null : irisWidth / 2,
),
),
],
)
);
}
}
//--------------------------------------------------CODE END