I've been banging my head against this for several days now, so I really hope somebody can shed some light.
I need a custom button that looks a certain way and runs simple animations on itself (really simple, like cross-fading to a different color and then back upon being pressed). I have written such a button, and it suits me perfectly in terms of looks and behavior.
But! In order for the animations to work, I had to derive my button from the StatefulWidget class. The problem is this: no matter what I do, I can't get the page to rebuild the button anew, with updated parameters.
I have implemented a simple on/off switch with two buttons to show what I mean. On this page I have two buttons: "Drop anchor" and "Retract anchor". I want only one button to be enabled at any given time. Pressing one of the buttons should disable it and enable the other one. But that doesn't happen!
I have put some text on the screen to illustrate that the page does indeed update on setState(). The text is governed by the same variable the buttons are. But for some reason Flutter updates the text, but not the buttons.
I've tried just about every solution I could find here.
Here's what I've tried:
I've added keys to my buttons and button's child. It helped with getting the AnimatedSwitch to do what I needed, but not with UI rebuilds.
I've tried dispatching notifications and calling setState() upon receiving them. No effect.
I've put a floating button to manually call setState() on the page to make sure it's called from the page widget, and not inside the button state. No effect.
I've tried wrapping the whole app in an AppBuilder, as was suggested on one of the threads here, and rebuilding the whole app on the press of the buttons, which works even for changing the Theme of the app. But it doesn't update my stateful buttons... Duh!
I've implemented the same example using ordinary stateless buttons, and it works exactly as I expect it to. So I'm about 75% sure the problem is that my custom button has its own state.
What else can I try?
Thank you for reading!
My example page code:
class _MyHomePageState extends State<MyHomePage> {
bool anchorIsDown = false;
void anchorUp() {
anchorIsDown = false;
setState(() {});
}
void anchorDown() {
anchorIsDown = true;
setState(() {});
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 3,
child: Container(
child: Center(
child: anchorIsDown ? Text('Anchor is down') : Text('Anchor is up'),
)
),
),
Flexible(
flex: 1,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
StatefulButton(
keyString: 'anchor_drop',
opacity: 0.2,
visible: true,
onPressedColor: Colors.greenAccent,
onPressed: !anchorIsDown ? anchorDown : null,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Drop anchor')
],
),
),
Expanded(child: SizedBox(width: 0, height: 0)),
StatefulButton(
keyString: 'anchor_retract',
opacity: 0.2,
visible: true,
onPressedColor: Colors.greenAccent,
onPressed: anchorIsDown ? anchorUp : null,
child: Text('Retract anchor'),
),
],
),
),
),
Flexible(
flex: 3,
child: Container(),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () { setState(() {}); },
),
);
}
}
My stateful button class:
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'dart:math';
class StatefulButton extends StatefulWidget {
late final String keyString;
late final bool visible;
late final double? opacity;
late final VoidCallback? onPressed;
late final Color? onPressedColor;
late final Widget child;
StatefulButton(
{required this.keyString,
this.visible = true,
this.opacity = 0.05,
this.onPressedColor,
this.onPressed,
required this.child})
: super(key: Key(keyString));
#override
State createState() => StatefulButtonState();
}
class StatefulButtonState extends State<StatefulButton> {
late final bool visible;
late double? opacity;
VoidCallback? onPressed;
late final Color? onPressedColor;
late final Widget child;
bool isAnimating = false;
#override
void initState() {
visible = widget.visible;
opacity = widget.opacity;
onPressed = widget.onPressed;
onPressedColor = widget.onPressedColor;
child = widget.child;
isAnimating = false;
super.initState();
}
void onPressedWrapper() async {
isAnimating = true;
setState(() {});
if (onPressed != null) onPressed!();
await Future.delayed(Duration(milliseconds: 200));
isAnimating = false;
setState(() {});
}
Widget buildChildContainer() {
if (!isAnimating)
return Container(
key: Key(widget.keyString + '_normalstate'),
color: Theme.of(context).buttonColor,
child: Center(
child: child,
),
);
return Container(
key: Key(widget.keyString + '_pressedstate'),
decoration: BoxDecoration(
color: onPressedColor,
borderRadius: BorderRadius.all(Radius.circular(3.0))
),
child: Center(
child: child,
),
);
}
//If the button is enabled, it should provide tap feedback.
//This function will build such a button using AnimatedCrossFade
Widget buildEnabledButton() {
return OutlinedButton(
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.all(1)),
side: MaterialStateProperty.all(BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
style: BorderStyle.solid)),
enableFeedback: false,
minimumSize: MaterialStateProperty.all(Size(150, 50)),
elevation: MaterialStateProperty.all(4.0),
shadowColor: MaterialStateProperty.all(Theme.of(context).shadowColor),
overlayColor: MaterialStateProperty.all(Colors.transparent),
backgroundColor:
MaterialStateProperty.all(Theme.of(context).buttonColor)),
onPressed: onPressed == null ? onPressed : onPressedWrapper,
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: buildChildContainer(),
switchInCurve: Curves.bounceOut,
switchOutCurve: Curves.easeOut,
),
);
}
Widget buildDisabledButton() {
if (!visible) opacity = 0;
return Stack(
children: [
OutlinedButton(
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.all(1)),
side: MaterialStateProperty.all(BorderSide(
color: Theme.of(context).dividerColor.withOpacity(sqrt(opacity!)),
width: 1,
style: visible ? BorderStyle.solid : BorderStyle.none)),
enableFeedback: false,
minimumSize: MaterialStateProperty.all(Size(1000, 1000)),
elevation: MaterialStateProperty.all(4.0 * opacity!),
shadowColor: visible
? MaterialStateProperty.all(
Theme.of(context).shadowColor.withOpacity(opacity!))
: MaterialStateProperty.all(Colors.transparent),
overlayColor: MaterialStateProperty.all(Colors.transparent),
backgroundColor: visible
? MaterialStateProperty.all(Theme.of(context)
.buttonColor
.withOpacity(sqrt(opacity!) / 2))
: MaterialStateProperty.all(Colors.transparent)),
onPressed: visible ? onPressed : null,
child: buildChildContainer()),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(3)),
color: Theme.of(context).canvasColor.withOpacity(1 - opacity!),
),
child: SizedBox(
child: Center(),
))
],
);
}
#override
Widget build(BuildContext context) {
return Expanded(
child: ((onPressed != null) && visible)
? buildEnabledButton()
: buildDisabledButton());
}
}
Related
I am building a quiz app which has a homepage that allows you to select the subject you want to be quizzed on and then displays the questions. However, when I run my code, it doesn't display this homepage and instead displays the questions that should come if the last button was pressed. Here are the relevant snippets of my code:
[main.dart]
import 'package:flutter/material.dart';
import './page.dart';
import './s_button.dart';
class _MyAppState extends State<MyApp> {
List subjects = ["biology", "chemistry", "physics"];
bool PageIndex = true;
String selected_subject = "";
void changePage(s) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
selected_subject = s;
PageIndex = false;
});
});
}
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.pink[100],
body: PageIndex ? Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Quiz App", style: TextStyle(fontSize: 24)),
SizedBox(height: 30),
Text("Select a subject"),
SizedBox(height: 40),
...subjects.map((sub){
return SubjectButton(pageHandler: changePage, subject: sub);
})
]
),
)
: QuizPage(selected_subject)
)
);
}
}
[s_button.dart]
import 'package:flutter/material.dart';
class SubjectButton extends StatelessWidget {
final Function pageHandler;
final String subject;
const SubjectButton({Key? key, required this.pageHandler, required this.subject}) : super(key: key);
#override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 120,
height: 60,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.pink,
elevation: 5,
),
onPressed: pageHandler(subject),
child: Text(subject)
)
),
SizedBox(height: 20)
],
);
}
}
When I run this however, QuizPage() is displayed with the question for physics, which is the last button as per my initial list. Somehow, my PageIndex is being set to false and my selected_subject is being set to "physics" before I even have a chance to click on the buttons. What is going wrong?
onPressed: pageHandler(subject) means, while building the widget, it will be called.
To call on runtime use
onPressed:()=> pageHandler(subject),
use
onPressed:()=> pageHandler(subject),
instead of
onPressed: pageHandler(subject),
Check the onPressed line, you're directly executing the function and assigning the return to the onPressed but not binding it.
onPressed: pageHandler(subject),
You may meant to do the following:
onPressed: () => pageHandler(subject),
Like this, it won't get automatically executed at first :)
I am trying to build a multiple choice Row from a list of Container widgets (wrapped in GestureDetector for onTap). When the user clicks on one of the Container widgets in the Row, that Container widget changes color (to blue) to reflect the user's selection. If the user then taps a different Container widget in the Row, THAT widget changes color to reflect the user's selection, and all others are reset to the initial color (white). Additionally, the user's choice (index?) must be retrievable.
All I have been able to do so far is create a Row of Container widgets that change color on tap, but function independently of one another. That is to say that the user may click on multiple selections, which is bad. I need some advice on how to break through to next level of functionality, where only one container may be selected and the selected value is passed-out.
Cheers,
T
//choice button
class ChoiceButton extends StatefulWidget {
final String label;
final bool isPressed;
const ChoiceButton({
Key key,
this.isPressed = false,
this.label,
}) : super(key: key);
#override
_ChoiceButtonState createState() => _ChoiceButtonState();
}
class _ChoiceButtonState extends State<ChoiceButton> {
bool _isPressed;
#override
void initState() {
super.initState();
_isPressed = widget.isPressed;
}
#override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_isPressed = !_isPressed;
});
},
child: Container(
height: 80,
width: 80,
decoration: BoxDecoration(
color: _isPressed ? Colors.blue : Colors.transparent,
border: Border.all(
color: Colors.blue,
width: 80 * 0.05,
),
),
child: Center(
child: Text(
widget.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _isPressed ? Colors.white : Colors.blue,
),
textAlign: TextAlign.center,
),
),
),
);
}
}
//choice row
class ChoiceRow extends StatefulWidget {
#override
_ChoiceRowState createState() => _ChoiceRowState();
}
class _ChoiceRowState extends State<ChoiceRow> {
bool isPressed = false;
String classChoice = '';
#override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: \[
SizedBox(width: 30),
ChoiceButton(
isPressed: isPressed,
label: 'A',
),
SizedBox(width: 10),
ChoiceButton(
isPressed: isPressed,
label: 'B',
),
SizedBox(width: 10),
ChoiceButton(
isPressed: isPressed,
label: 'C',
),
\],
);
}
}
I am providing two answers for this .... Use whichever one you prefer.
The easy one :
So Flutter provides a widget named ToggleButton which will satisfy all the needs mentioned above. Follow this documentation for more information about this widget.
You can add your custom ChoiceButton design in the widget list of the toggle button and with some tweak you will also be able to achieve your ChioceRow design too.
Now if you are someone who like to make everything from scratch (like me :P) then I have made some changes in the code you provided above which will satisfy all your needs. Below is the edited code.
class ChoiceRow extends StatefulWidget {
#override
_ChoiceRowState createState() => _ChoiceRowState();
}
class _ChoiceRowState extends State<ChoiceRow> {
List<bool> isPressedList = [false,false,false];
String classChoice = '';
#override
Widget build(BuildContext context) {
print("Status L $isPressedList");
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(width: 30),
GestureDetector(
onTap: (){
print("Hello");
setState(() {
isPressedList[0] = true;
isPressedList[1] = false;
isPressedList[2] = false;
});
},
child: ChoiceButton(
isPressed: isPressedList[0],
label: 'A',
),
),
SizedBox(width: 10),
GestureDetector(
onTap: (){
setState(() {
isPressedList[0] = false;
isPressedList[1] = true;
isPressedList[2] = false;
});
},
child: ChoiceButton(
isPressed: isPressedList[1],
label: 'B',
),
),
SizedBox(width: 10),
GestureDetector(
onTap: (){
setState(() {
isPressedList[0] = false;
isPressedList[1] = false;
isPressedList[2] = true;
});
},
child: ChoiceButton(
isPressed: isPressedList[2],
label: 'C',
),
),
],
);
}
}
class ChoiceButton extends StatelessWidget {
final String label;
final bool isPressed;
ChoiceButton({this.label,this.isPressed});
#override
Widget build(BuildContext context) {
return Container(
height: 80,
width: 80,
decoration: BoxDecoration(
color: isPressed ? Colors.blue : Colors.transparent,
border: Border.all(
color: Colors.blue,
width: 80 * 0.05,
),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isPressed ? Colors.white : Colors.blue,
),
textAlign: TextAlign.center,
),
),
);
}
}
CHANGES :
I made a list (onPressedList) which keeps track of the current status of the toggle buttons (Like which is on and which others are off).
I moved the GestureDetector wrap on the button in the ChoiceRow class. This is because it will be difficult to pass the onTap result from ChoiceButton to ChoiceRow. (Now if you want the gesture detector in the Button class itself then you can make the list global or a static value in a completely different so that it can be properly accessed)
I made the ChoiceButton class Stateless as there is no need to keep it Stateful now.
The two things I did were adding a list that tracks the current status of the toggle buttons and when one toggle button is active all the others will be deactivated.
Now it is working as you mentioned above and you will also be able to keep track of the current status of all the buttons through "isPressedList".
GLHF :)
My issue is what the title says - I have a gridview of flip cards. When I flip a few over, scroll past them and then scroll back up the cards have flipped back. I don't really want that to happen because it's supposed to be that every time the user flips a card a point is added to a total, and then when they flip it back a point is taken away from the total. I've tried "AutomaticKeepAliveClientMixin", which works to preserve the state when I have another tab, but it doesn't seem to help keep the cards flipped when I scroll.
Apologies in advance if an obvious solution exists here and I've missed it. I did try to find a solution online.
Here is the code from the dart file I'm working with:
import 'package:flutter/material.dart';
import 'package:flip_card/flip_card.dart';
import 'statenames.dart';
import 'globalVariables.dart';
StateNames stateObject = new StateNames();
class GridOne extends StatefulWidget {
final Function updateCounter;
final Function decreaseCount;
GridOne(this.updateCounter, this.decreaseCount);
#override
_GridOneState createState() => _GridOneState();
}
class _GridOneState extends State<GridOne>
with AutomaticKeepAliveClientMixin {
#override
bool get wantKeepAlive => true;
int points = 0;
#override
Widget build(BuildContext context) {
return new Scaffold(
body: GridView.count(
crossAxisCount: 4,
children: List.generate(52, (index){
return Card(
elevation: 0.0,
margin: EdgeInsets.only(left: 3.0, right: 3.0, top: 9.0, bottom: 0.0),
color: Color(0x00000000),
child: FlipCard(
direction: FlipDirection.HORIZONTAL,
speed: 1000,
onFlipDone: (status) {
setState(() {
(status)
? widget.decreaseCount()
: widget.updateCounter();
});
print(counter);
},
front: Container(
decoration: BoxDecoration(
color: Color(0xFF006666),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
FittedBox(fit:BoxFit.fitWidth,
child: Text(stateObject.stateNames[index], style: TextStyle(fontFamily: 'Architects Daughter', color: Colors.white), )
),
Text('',
style: Theme.of(context).textTheme.body1),
],
),
),
back: Container(
decoration: BoxDecoration(
color: Color(0xFF006666),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image(image: AssetImage(stateObject.licensePlatePaths[index])),
],
),
),
),
);
})
),
);
}
}
Thank you so much for reading.
I did hit trial to achieve what you wanted and i think it's only possible if you use column. I even tried using simple ListView but still the the card will return to front face
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Test(),
);
}
}
class Test extends StatefulWidget{
#override
_Test createState() => _Test();
}
class _Test extends State<Test>{
List<Widget> list = [];
_Test(){
list = new List.filled(30, flipCards());
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: list,
),
)
);
}
Widget flipCards(){
return Container(
height: 70,
child: FlipCard(
flipOnTouch: true,
front: Card(
child: Container(
alignment: Alignment.center,
child: Text('Toggle'),
),
),
back: Card(
child: Container(
alignment: Alignment.center,
child: Text('Back'),
),
),
)
);
}
}
You need to use the AutomaticKeepAliveClientMixin on the children of the GridView
So make a new stateful widget for your cards and use the keepalive there.
Also for the AutomaticKeepAliveClientMixin you need to call super.build(context); in your build method
I'm stuck with making a scrollable list like Google Task app when you reach end of the list if any task is completed it shown in another list with custom header as you can see here, I'm using sliver
Widget showTaskList() {
final todos = Hive.box('todos');
return ValueListenableBuilder(
valueListenable: Hive.box('todos').listenable(),
builder: (context, todoData, _) {
int dataLen = todos.length;
return CustomScrollView(
slivers: <Widget>[
SliverAppBar(
floating: true,
expandedHeight: 100,
flexibleSpace: Container(
padding: EdgeInsets.only(
left: MediaQuery.of(context).size.width / 10,
top: MediaQuery.of(context).size.height / 17),
height: 100,
color: Colors.white,
child: Text(
'My Task',
style: TextStyle(fontSize: 30.0, fontWeight: FontWeight.w600),
),
),
),
SliverList(
delegate:
SliverChildBuilderDelegate((BuildContext context, int index) {
final todoData = todos.getAt(index);
Map todoJson = jsonDecode(todoData);
final data = Todo.fromJson(todoJson);
return MaterialButton(
padding: EdgeInsets.zero,
onPressed: () {},
child: Container(
color: Colors.white,
child: ListTile(
leading: IconButton(
icon: data.done
? Icon(
Icons.done,
color: Colors.red,
)
: Icon(
Icons.done,
),
onPressed: () {
final todoData = Todo(
details: data.details,
title: data.title,
done: data.done ? false : true);
updataTodo(todoData, index);
}),
title: Text(
data.title,
style: TextStyle(
decoration: data.done
? TextDecoration.lineThrough
: TextDecoration.none),
),
subtitle: Text(data.details),
trailing: IconButton(
icon: Icon(Icons.delete_forever),
onPressed: () {
todos.deleteAt(index);
}),
),
),
);
}, childCount: dataLen),
),
],
);
});
}
ShowTaskList is called on
Scaffold(
body: SafeArea(
child: Column(children: <Widget>[
Expanded(
child: showTaskList()
),
]),
),
I tried OffStageSliver to make an widget disappear if no complete todo is present but that did not work and also can not use any other widget on CustomScrollView because that conflict with viewport because it only accept slivers widget.
Here what i have achieved so far
You can try use ScrollController put it on CustomScrollView and listen to it's controller in initState like this :
#override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
// If it reach end do something here...
}
});
}
I suggest you make bool variable to show your widget, initialize it with false and then after it reach end of controller call setState and make your variable true, which you can't call setState in initState so you have to make another function to make it work like this:
reachEnd() {
setState(() {
end = true;
});
}
Put that function in initState. And make condition based on your bool variabel in your widget
if(end) _yourWidget()
Just like that. I hope you can understand and hopefully this is working the way you want.
I want to make tutorial screen that show to user at beginning. it's like below :
my specific question, how to make some certain elements will show normally and other are opaque ?
also the arrow and text, how to make them point perfectly based on mobile device screen size (mobile responsiveness) ?
As RoyalGriffin mentioned, you can use highlighter_coachmark library, and I am also aware of the error you are getting, the error is there because you are using RangeSlider class which is imported from 2 different packages. Can you try this example in your app and check if it is working?
Add highlighter_coachmark to your pubspec.yaml file
dependencies:
flutter:
sdk: flutter
highlighter_coachmark: ^0.0.3
Run flutter packages get
Example:
import 'package:highlighter_coachmark/highlighter_coachmark.dart';
void main() => runApp(MaterialApp(home: HomePage()));
class HomePage extends StatefulWidget {
#override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
GlobalKey _fabKey = GlobalObjectKey("fab"); // used by FAB
GlobalKey _buttonKey = GlobalObjectKey("button"); // used by RaisedButton
#override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
key: _fabKey, // setting key
onPressed: null,
child: Icon(Icons.add),
),
body: Center(
child: RaisedButton(
key: _buttonKey, // setting key
onPressed: showFAB,
child: Text("RaisedButton"),
),
),
);
}
// we trigger this method on RaisedButton click
void showFAB() {
CoachMark coachMarkFAB = CoachMark();
RenderBox target = _fabKey.currentContext.findRenderObject();
// you can change the shape of the mark
Rect markRect = target.localToGlobal(Offset.zero) & target.size;
markRect = Rect.fromCircle(center: markRect.center, radius: markRect.longestSide * 0.6);
coachMarkFAB.show(
targetContext: _fabKey.currentContext,
markRect: markRect,
children: [
Center(
child: Text(
"This is called\nFloatingActionButton",
style: const TextStyle(
fontSize: 24.0,
fontStyle: FontStyle.italic,
color: Colors.white,
),
),
)
],
duration: null, // we don't want to dismiss this mark automatically so we are passing null
// when this mark is closed, after 1s we show mark on RaisedButton
onClose: () => Timer(Duration(seconds: 1), () => showButton()),
);
}
// this is triggered once first mark is dismissed
void showButton() {
CoachMark coachMarkTile = CoachMark();
RenderBox target = _buttonKey.currentContext.findRenderObject();
Rect markRect = target.localToGlobal(Offset.zero) & target.size;
markRect = markRect.inflate(5.0);
coachMarkTile.show(
targetContext: _fabKey.currentContext,
markRect: markRect,
markShape: BoxShape.rectangle,
children: [
Positioned(
top: markRect.bottom + 15.0,
right: 5.0,
child: Text(
"And this is a RaisedButton",
style: const TextStyle(
fontSize: 24.0,
fontStyle: FontStyle.italic,
color: Colors.white,
),
),
)
],
duration: Duration(seconds: 5), // this effect will only last for 5s
);
}
}
Output:
You can use this library to help you achieve what you need. It allows you to mark views which you want to highlight and how you want to highlight them.
Wrap your current top widget with a Stack widget, having the first child of the Stack your current widget.
Below this widget add a Container with black color, wrapped with Opacity like so:
return Stack(
children: <Widget>[
Scaffold( //first child of the stack - the current widget you have
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
Text("Foo"),
Text("Bar"),
],
),
)),
Opacity( //seconds child - Opaque layer
opacity: 0.7,
child: Container(
decoration: BoxDecoration(color: Colors.black),
),
)
],
);
you then need to create image assets of the descriptions and arrows, in 1x, 2x, 3x resolutions, and place them in your assets folder in the appropriate structure as described here: https://flutter.dev/docs/development/ui/assets-and-images#declaring-resolution-aware-image-assets
you can then use Image.asset(...) widget to load your images (they will be loaded in the correct resolution), and place these widgets on a different container that will also be a child of the stack, and will be placed below the black container in the children list (the Opacity widget on the example above).
It should be mentioned that instead of an opaque approach the Material-oriented feature_discovery package uses animation and integrates into the app object hierarchy itself and therefore requires less custom highlight programming. The turnkey solution also supports multi-step highlights.
Screenshot (Using null-safety):
Since highlighter_coachmark doesn't support null-safety as of this writing, use tutorial_coach_mark which supports null-safety.
Full Code:
class HomePage extends StatefulWidget {
#override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late final List<TargetFocus> targets;
final GlobalKey _key1 = GlobalKey();
final GlobalKey _key2 = GlobalKey();
final GlobalKey _key3 = GlobalKey();
#override
void initState() {
super.initState();
targets = [
TargetFocus(
identify: 'Target 1',
keyTarget: _key1,
contents: [
TargetContent(
align: ContentAlign.bottom,
child: _buildColumn(title: 'First Button', subtitle: 'Hey!!! I am the first button.'),
),
],
),
TargetFocus(
identify: 'Target 2',
keyTarget: _key2,
contents: [
TargetContent(
align: ContentAlign.top,
child: _buildColumn(title: 'Second Button', subtitle: 'I am the second.'),
),
],
),
TargetFocus(
identify: 'Target 3',
keyTarget: _key3,
contents: [
TargetContent(
align: ContentAlign.left,
child: _buildColumn(title: 'Third Button', subtitle: '... and I am third.'),
)
],
),
];
}
Column _buildColumn({required String title, required String subtitle}) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
),
Padding(
padding: const EdgeInsets.only(top: 10.0),
child: Text(subtitle),
)
],
);
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(20),
child: Stack(
children: [
Align(
alignment: Alignment.topLeft,
child: ElevatedButton(
key: _key1,
onPressed: () {},
child: Text('Button 1'),
),
),
Align(
alignment: Alignment.center,
child: ElevatedButton(
key: _key2,
onPressed: () {
TutorialCoachMark(
context,
targets: targets,
colorShadow: Colors.cyanAccent,
).show();
},
child: Text('Button 2'),
),
),
Align(
alignment: Alignment.bottomRight,
child: ElevatedButton(
key: _key3,
onPressed: () {},
child: Text('Button 3'),
),
),
],
),
),
);
}
}
Thanks to #josxha for the suggestion.
If you don't want to rely on external libraries, you can just do it yourself. It's actually not that hard.
Using a stack widget you can put the semi-transparent overlay on top of everything. Now, how do you "cut holes" into that overlay that emphasize underlying UI elements?
Here is an article that covers the exact topic: https://www.flutterclutter.dev/flutter/tutorials/how-to-cut-a-hole-in-an-overlay/2020/510/
I will summarize the possibilities you have:
Use a ClipPath
By using a CustomClipper, given a widget, you can define what's being drawn and what's not. You can then just draw a rectangle or an oval around the relevant underlying UI element:
class InvertedClipper extends CustomClipper<Path> {
#override
Path getClip(Size size) {
return Path.combine(
PathOperation.difference,
Path()..addRect(
Rect.fromLTWH(0, 0, size.width, size.height)
),
Path()
..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
..close(),
);
}
#override
bool shouldReclip(CustomClipper<Path> oldClipper) => true;
}
Insert it like this in your app:
ClipPath(
clipper: InvertedClipper(),
child: Container(
color: Colors.black54,
),
);
Use a CustomPainter
Instead of cutting a hole in an overlay, you can directly draw a shape that is as big as the screen and has the hole already cut out:
class HolePainter extends CustomPainter {
#override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black54;
canvas.drawPath(
Path.combine(
PathOperation.difference,
Path()..addRect(
Rect.fromLTWH(0, 0, size.width, size.height)
),
Path()
..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
..close(),
),
paint
);
}
#override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
Insert it like this:
CustomPaint(
size: MediaQuery.of(context).size,
painter: HolePainter()
);
Use ColorFiltered
This solution works without paint. It cuts holes where children in the widget trees are inserted by using a specific blendMode:
ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black54,
BlendMode.srcOut
),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
color: Colors.transparent,
),
child: Align(
alignment: Alignment.bottomRight,
child: Container(
margin: const EdgeInsets.only(right: 4, bottom: 4),
height: 80,
width: 80,
decoration: BoxDecoration(
// Color does not matter but must not be transparent
color: Colors.black,
borderRadius: BorderRadius.circular(40),
),
),
),
),
],
),
);