How to verify a widget is "offscreen" - flutter

bounty info: I'll accept your answer if:
isn't something along the line do this instead
the code sample is mostly unchanged
produce successful test, not just some quote from docs
doesn't need any extra package
[edit : 07/02/21] following Miyoyo#5957 on flutter community on
discord #iapicca Convert widget position to global, get width height, add both, and see if the resulting bottom right position is on screen? and using the following answers as reference:
test widget global position
test widget size
flutter_test dimensions issue
given the code sample below (also runnable on dartpad)
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
final _testKey = GlobalKey();
const _fabKey = ValueKey('fab');
final _onScreen = ValueNotifier<bool>(true);
void main() => runApp(_myApp);
const _myApp = MaterialApp(
home: Scaffold(
body: MyStage(),
floatingActionButton: MyFAB(),
),
);
class MyFAB extends StatelessWidget {
const MyFAB() : super(key: const ValueKey('MyFAB'));
#override
Widget build(BuildContext context) => FloatingActionButton(
key: _fabKey,
onPressed: () => _onScreen.value = !_onScreen.value,
);
}
class MyStage extends StatelessWidget {
const MyStage() : super(key: const ValueKey('MyStage'));
#override
Widget build(BuildContext context) => Stack(
children: [
ValueListenableBuilder(
child: FlutterLogo(
key: _testKey,
),
valueListenable: _onScreen,
builder: (context, isOnStage, child) => AnimatedPositioned(
top: MediaQuery.of(context).size.height *
(_onScreen.value ? .5 : -1),
child: child,
duration: const Duration(milliseconds: 100),
),
),
],
);
}
I want to test is the widget is off screen
here's the test code so far
void main() {
testWidgets('...', (tester) async {
await tester.pumpWidget(_myApp);
final rect = _testKey.currentContext.findRenderObject().paintBounds;
expect(tester.getSize(find.byKey(_testKey)), rect.size,
reason: 'size should match');
final lowestPointBefore = rect.bottomRight.dy;
print('lowest point **BEFORE** $lowestPointBefore ${DateTime.now()}');
expect(lowestPointBefore > .0, true, reason: 'should be on-screen');
await tester.tap(find.byKey(_fabKey));
await tester.pump(const Duration(milliseconds: 300));
final lowestPointAfter =
_testKey.currentContext.findRenderObject().paintBounds.bottomRight.dy;
print('lowest point **AFTER** $lowestPointAfter ${DateTime.now()}');
expect(lowestPointAfter > .0, false, reason: 'should be off-screen');
});
}
and the logs produced
00:03 +0: ...
lowest point **BEFORE** 24.0 2021-02-07 16:28:08.715558
lowest point **AFTER** 24.0 2021-02-07 16:28:08.850733
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure object was thrown running a test:
Expected: <false>
Actual: <true>
When the exception was thrown, this was the stack:
#4 main.<anonymous closure> (file:///home/francesco/projects/issue/test/widget_test.dart:83:5)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)
...
This was caught by the test expectation on the following line:
file:///home/francesco/projects/issue/test/widget_test.dart line 83
The test description was:
...
════════════════════════════════════════════════════════════════════════════════════════════════════
00:03 +0 -1: ... [E]
Test failed. See exception logs above.
The test description was: ...
00:03 +0 -1: Some tests failed.
I'm not sure if my approach is correct
and the time in the print suggest me that
lowest point **BEFORE** 24.0 2021-02-07 16:28:08.715558
lowest point **AFTER** 24.0 2021-02-07 16:28:08.850733
suggest me that
await tester.pumpAndSettle(Duration(milliseconds: 300));
doesn't do what I think it does

Problems are:
We were trying to find the rect of FlutterLogo but FlutterLogo rect will remain same the parent AnimatedPositioned widget's location are actually changing.
Even though we now start to check for AnimatedPositioned paintBounds it will still be the same as we are not changing width but the position it self.
Solution:
Get the screen rect by topWidget for me it's Scaffold. (if we have different widgets like HomeScreen which contains FAB button we just need to find that rect)
Before click I'm checking if fab button is on-screen or not
Tap and pump the widget and let it settle.
Search for widget rect and it will be out of the screen i.e. in our case -600
Added comments in the code it self
testWidgets('...', (tester) async {
await tester.pumpWidget(MyApp);
//check screen width height - here I'm checking for scaffold but you can put some other logic for screen size or parent widget type
Rect screenRect = tester.getRect(find.byType(Scaffold));
print("screenRect: $screenRect");
//checking previous position of the widget - on our case we are animating widget position via AnimatedPositioned
// which in itself is a statefulwidget and has Positioned widget inside
//also if we have multiple widgets of same type give them uniqueKey
AnimatedPositioned widget =
tester.firstWidget(find.byType(AnimatedPositioned));
double topPosition = widget.top;
print(widget);
print("AnimatedPositioned topPosition: $topPosition}");
expect(
screenRect.bottom > topPosition && screenRect.top < topPosition, true,
reason: 'should be on-screen');
//click button to animate the widget and wait
await tester.tap(find.byKey(fabKey));
//this will wait for animation to settle or call pump after duration
await tester.pumpAndSettle(const Duration(milliseconds: 300));
//check after position of the widget
AnimatedPositioned afterAnimationWidget =
tester.firstWidget(find.byType(AnimatedPositioned));
double afterAnimationTopPosition = afterAnimationWidget.top;
Rect animatedWidgetRect = tester.getRect(find.byType(AnimatedPositioned));
print("rect of widget : $animatedWidgetRect");
expect(
screenRect.bottom > afterAnimationTopPosition &&
screenRect.top < afterAnimationTopPosition,
false,
reason: 'should be off-screen');
});
Note: replaced _ from code as it was hiding the object from test file.
Output:
screenRect: Rect.fromLTRB(0.0, 0.0, 800.0, 600.0)
fab clicked
rect of widget : Rect.fromLTRB(0.0, -600.0, 24.0, -576.0)

I found this answer (in particular the code inside the onNotification), which kind of does what (I think) you want. It finds the RenderObject using the current context of the key. Afterwards it finds the RenderAbstractViewport using this RenderObject, and checks the offSetToReveal. Using this offset you can determine whether the current RenderObject is being displayed or not (using a simple comparison).
I'm not a 100% sure this will work / is what you want, but hopefully it can push you in the right direction.
Also (even though you stated you didn't want any external package), on the same question someone recommended this package, which can be useful for others having the same problem but who are open to using an external package.

I want to thank #parth-dave
for his answer, that I happily reward with the bounty
and Miyoyo referenced in the question
I want to offer my own implementation built on his approach
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
// !! uncomment tge line below to run as test app
// void main() => runApp(_myApp);
class Keys {
static final subject = UniqueKey();
static final parent = UniqueKey();
static final trigger = UniqueKey();
}
final _onScreen = ValueNotifier<bool>(true);
Widget get app => MaterialApp(
home: Scaffold(
key: Keys.parent,
body: MyStage(),
floatingActionButton: MyFAB(),
),
);
class MyFAB extends StatelessWidget {
const MyFAB() : super(key: const ValueKey('MyFAB'));
#override
Widget build(BuildContext context) => FloatingActionButton(
key: Keys.trigger,
onPressed: () => _onScreen.value = !_onScreen.value,
);
}
class MyStage extends StatelessWidget {
const MyStage() : super(key: const ValueKey('MyStage'));
#override
Widget build(BuildContext context) => Stack(
children: [
ValueListenableBuilder(
child: FlutterLogo(
key: Keys.subject,
),
valueListenable: _onScreen,
builder: (context, isOnStage, child) => AnimatedPositioned(
top: MediaQuery.of(context).size.height *
(_onScreen.value ? .5 : -1),
child: child,
duration: const Duration(milliseconds: 100),
),
),
],
);
}
void main() {
group('`AnimatedPositined` test', () {
testWidgets(
'WHEN no interaction with `trigger` THEN `subject` is ON SCREEN',
(tester) async {
await tester.pumpWidget(app);
final parent = tester.getRect(find.byKey(Keys.parent));
final subject = tester.getRect(find.byKey(Keys.subject));
expect(parent.overlaps(subject), true, reason: 'should be ON-screen');
});
testWidgets('WHEN `trigger` tapped THEN `subject` is OFF SCREEN`',
(tester) async {
await tester.pumpWidget(app);
await tester.tap(find.byKey(Keys.trigger));
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final parent = tester.getRect(find.byKey(Keys.parent));
final subject = tester.getRect(find.byKey(Keys.subject));
expect(parent.overlaps(subject), false, reason: 'should be OFF-screen');
});
});
}

Related

How to manage multiple ScrollView widgets using one useScrollController() hook?

Flutter documentation for ScrollController has this paragraph:
Scroll controllers are typically stored as member variables in State objects and are reused in each State.build. A single scroll controller can be used to control multiple scrollable widgets, but some operations, such as reading the scroll offset, require the controller to be used with a single scrollable widget.
Does this mean that we cannot pass the same ScrollController to different ScrollView widgets to read ScrollController.offset?
What I'm trying to accomplish is this:
There are two screens. Each screen has a ListView.builder() widget.
Through parameters I pass from screen 1 to screen 2 an object ScrollController and apply it to ListView.
I use scrolling and the offset value changes, but as soon as I move/return to another screen, the offset is knocked down to 0.0 and I see the beginning of the list.
The same ScrollController object is used all the time (hashcode is the same)
How can we use one ScrollController object for different ScrollView widgets, so that the offset is not knocked down when moving from screen to screen?
This problem can be solved a bit if, when switching to another screen, we create a new ScrollController object with initialScrollOffset = oldScrollController.offset and pass it to ScrollView.
Update:
I don't seem to understand how to use flutter_hooks. I created a simple example showing that if we use separate widgets and specify ScrollController as a parameter, the scroll is reset to position 0.0.
Reference for an example:
https://dartpad.dev/?id=d31f4714ce95869716c18b911fee80c1
How do we overcome this?
For now, the best solution I can offer is to pass final ValueNotifier<double> offsetState; instead of final ScrollController controller; as a widget parameter.
Then, in each widget we create a ScrollController. By listening to it via the useListenableSelector hook we change the offsetState.
To avoid unnecessary rebuilding, we use the useValueNotifier hook.
A complete example looks like this:
void main() => runApp(
const MaterialApp(
debugShowCheckedModeBanner: false,
home: MyApp(),
),
);
class MyApp extends HookWidget {
const MyApp();
#override
Widget build(BuildContext context) {
print('#build $MyApp');
final isPrimaries = useState(true);
final offsetState = useValueNotifier(0.0);
return Scaffold(
appBar: AppBar(
title: Text(isPrimaries.value
? 'Colors.primaries List'
: 'Colors.accents List'),
actions: [
IconButton(
onPressed: () => isPrimaries.value = !isPrimaries.value,
icon: const Icon(Icons.ac_unit_sharp),
)
],
),
body: isPrimaries.value
? ListPrimaries(offsetState: offsetState)
: ListAccents(offsetState: offsetState),
);
}
}
class ListAccents extends HookConsumerWidget {
const ListAccents({
Key? key,
required this.offsetState,
}) : super(key: key);
final ValueNotifier<double> offsetState;
#override
Widget build(BuildContext context, WidgetRef ref) {
print('#build $ListAccents');
final controller =
useScrollController(initialScrollOffset: offsetState.value);
useListenableSelector(controller, () {
print(controller.positions);
if (controller.hasClients) {
offsetState.value = controller.offset;
}
return null;
});
return ListView(
primary: false,
controller: controller,
children: Colors.accents
.map((color) => Container(color: color, height: 100))
.toList(),
);
}
}
class ListPrimaries extends HookConsumerWidget {
const ListPrimaries({
Key? key,
required this.offsetState,
}) : super(key: key);
final ValueNotifier<double> offsetState;
#override
Widget build(BuildContext context, WidgetRef ref) {
print('#build $ListPrimaries');
final controller =
useScrollController(initialScrollOffset: offsetState.value);
useListenableSelector(controller, () {
print(controller.positions);
if (controller.hasClients) {
offsetState.value = controller.offset;
}
return null;
});
return ListView(
primary: false,
controller: controller,
children: Colors.primaries
.map((color) => Container(color: color, height: 100))
.toList(),
);
}
}
Another idea was to use useEffect hook and give it a function to save the last value at the moment of dispose():
useEffect(() {
return () {
offsetState.value = controller.offset;
};
}, const []);
But the problem is that at this point, we no longer have clients.
Bonus:
If our task is to synchronize the scroll of the ListView, another useListenableSelector hook added to each of the widgets solves this problem. Remind that we cannot use the same `ScrollController' for two or more lists at the same time.
useListenableSelector(offsetState, () {
if (controller.hasClients) {
// if the contents of the ListView are of different lengths, then do nothing
if (controller.position.maxScrollExtent < offsetState.value) {
return;
}
controller.jumpTo(offsetState.value);
}
});

Threw an exception when the notifier tried to update its state error in flutter_riverpod

I am developing an app with flutter and I am using flutter_riverpod package for state managment. But I am getting an error.
I have 2 main widget inside my screen. These are "ReadTopBarW" widget and "ReadArticleW" widget.
I am getting datas with http post with futureprovider with riverpod. There is no problem so far.
My purpose is change the icon with a response value.
I am getting the value in "ReadArticleW" and I am writing the value for StateProvider. And I will watch the value in "ReadTopBarW".
This is my "ReadArticleW"
import 'dart:convert';
import 'package:eem_flutter_app/services/get_one_article.dart';
import 'package:eem_flutter_app/widgets/read/shimmer_read_w.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final isSavedVider = StateProvider((_) => '');
class ReadArticleW extends ConsumerWidget {
const ReadArticleW({Key? key}) : super(key: key);
#override
Widget build(BuildContext context, WidgetRef ref) {
final futureCatFacts = ref.watch(singleArticleFProvider);
return SingleChildScrollView(
child: futureCatFacts.when(
data: (datax) {
final decodedData = json.decode(datax.body);
final isSaved = decodedData[1].toString();
ref.read(isSavedVider.notifier).state = isSaved;
return Column(
children: [
Text(
decodedData[0]['articleTitle'].toString(),
),
Image.network(
decodedData[0]['articleLImage'].toString()),
],
);
},
error: (err, stack) => Text('Error: $err'),
loading: () => const ShimmerReadW(),
),
);
}
}
This my "ReadTopBarW"
import 'package:eem_flutter_app/widgets/read/read_article_w.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ReadTopBarW extends ConsumerWidget {
const ReadTopBarW({Key? key}) : super(key: key);
#override
Widget build(BuildContext context, WidgetRef ref) {
final x = ref.watch(isSavedVider);
print(x);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
onTap: () {
Navigator.pop(context);
},
child: const Icon(Icons.arrow_back_ios)),
Row(
children: const [
"0" == "1"
? Icon(Icons.bookmark_border)
: Icon(Icons.bookmark_outlined),
SizedBox(
width: 10,
),
Icon(Icons.ios_share),
],
),
],
);
}
}
This is error text. The error is vscode red error that you know
Exception has occurred.
StateNotifierListenerError (At least listener of the StateNotifier Instance of 'StateController<String>' threw an exception
when the notifier tried to update its state.
The exceptions thrown are:
setState() or markNeedsBuild() called during build.
This UncontrolledProviderScope widget cannot be marked as needing to build because the framework is already in the process of building widgets. A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.
The widget on which setState() or markNeedsBuild() was called was:
UncontrolledProviderScope
The widget which was currently being built when the offending call was made was:
ReadArticleW
Please focus on th isSavedVider because the problem is here. There is no problem in singleArticleProvider.
note:
I simplified the code to publish here.
You shouldn't update a StateProvider in the build method, the error is coming from here:
child: futureCatFacts.when(
data: (datax) {
final decodedData = json.decode(datax.body);
final isSaved = decodedData[1].toString();
ref.read(isSavedVider.notifier).state = isSaved;
What you can you is watch the singleArticleFProvider in isSavedVider and set the value from the extracted response, this way, when there is an update to the response, it isSavedVider gets the update value like so:
final isSavedVider = StateProvider((ref){
final futureCatFacts = ref.watch(singleArticleFProvider);
if(futureCatFacts is AsyncData){
final decodedData = json.decode(futureCatFacts.value.body);
final isSaved = decodedData[1].toString();
return isSaved;
}
return "";
});

NoSuchMethodError when taking Screenshot

I am trying to take a Screanshot of a Stak with a list of iteams in it. It displays normaly and works, but when i try to take screenshot of the Widget I resive:
NoSuchMethodError (NoSuchMethodError: The getter 'stateWidget' was called on null.
Receiver: null
Tried calling: stateWidget)
(I use a Inhereted widget)
Her is the Widget I am trying to take a Screenshot of
class BlinkSkjerm extends StatelessWidget {
#override
Widget build(BuildContext context) {
final provider = InheritedDataProvider.of(context);
final data = provider.historikken[provider.index];
return SizedBox(
height: 400,
child: Stack(
children: data.inMoveableItemsList,
));
}
}
and her is the onPress funtion:
onPressed: () async {
final controler = ScreenshotController();
final bytes = await controler.captureFromWidget(BlinkSkjerm());
setState(() {
this.bytes = bytes;
});
}
you used InheritedDataProvider in wrong way. you did not provide data that needed in BlinkSkjerm.
you want to take screen shot from widget that not in the tree, but that widget need data that should provide before build it which you did not provide it.
this approach work this way:
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InheritedDataProvider(
child: BlinkSkjerm(),
data:'some string',
)),
);
this way you can use
final provider = InheritedDataProvider.of(context);
and make sure it is not null.
for your situation I recommended to do something like this:
onPressed: () async {
final controler = ScreenshotController();
final bytes = await controler.captureFromWidget(InheritedDataProvider(
child: BlinkSkjerm(),
data:'some string',
));
setState(() {
this.bytes = bytes;
});
}
for more information see this page

Does flutter_test support testing 'leaf' widgets?

Suppose I create a simple widget, such as this:
class RightArrowButton extends StatelessWidget {
const RightArrowButton({VoidCallback onPressed})
: this._onPressed = onPressed;
final VoidCallback _onPressed;
#override
Widget build(BuildContext context) {
return IconButton(
visualDensity: VisualDensity.compact,
onPressed: _onPressed,
icon: _buildIcon(iconData()),
);
}
/// Overrride to set a different icon.
IconData iconData() => Icons.play_arrow;
Widget _buildIcon(IconData icon) => Icon(icon, color: Colors.blue);
}
Further suppose that I wish to unit test this widget. I know, this is an incredibly simple widget, but I might be attempting to use TDD to write the widget in the first place. In any case, I could apply the concept to a more complex widget.
In this case, the test would be very simple: Construct the widget, passing it an onPressed function whose side-effects can be checked to validate that the onPressed method is actually called when someone taps on the widget.
Sample test code:
void main() {
testWidgets('RightArrowButton', (WidgetTester tester) async {
int counter = 0;
await tester.pumpWidget(RightArrowButton(
onPressed: () => counter++,
));
expect(0, equals(counter));
var button = find.byType(IconButton);
await tester.tap(button);
expect(1, equals(counter));
});
}
However, when I do this, I get the following error:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building IconButton(Icon, padding: EdgeInsets.all(8.0), dirty):
No Material widget found.
IconButton widgets require a Material widget ancestor.
I understand the error after reading this SO question:
No Material widget found
So -- Is there a way to unit test widgets in the way I want? Or am I forced to test a whole assembly of widgets in order to get a valid Material widget ancestor (which is more like a small integration test)?
I found a solution which works. Flutter experts, is the following approach the only or best way to achieve my goal of testing individual widgets in isolation?
void main() {
testWidgets('arrow test', (WidgetTester tester) async {
int counter = 0;
// must include Card to get Material;
// must include MaterialApp to get Directionality
await tester.pumpWidget(MaterialApp(
home: Card(
child: RightArrowButton(
onPressed: () => counter++,
),
),
));
await tester.tap(find.byType(RightArrowButton));
expect(1, equals(counter));
});
}

How to find off-screen ListView child in widget tests?

When displaying multiple children in a ListView, if a child is off-screen it can't be found by a widget test. Here's a full example:
main.dart
import 'package:flutter/material.dart';
void main() => runApp(App());
class App extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: Test()));
}
}
class Test extends StatelessWidget {
#override
Widget build(BuildContext context) {
return ListView(
children: <Widget>[
Container(
height: 600,
color: Colors.red,
),
Text("Find me!"),
],
);
}
}
main_test.dart
import 'package:flutter_app/main.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets("Find text", (WidgetTester tester) async {
final testableWidget = App();
await tester.pumpWidget(testableWidget);
expect(find.text("Find me!"), findsOneWidget);
});
}
This test fails, however if I change the height of the Container in main.dart to 599 it works.
Anyone know why this happens? Is it a bug? Is there a way around it?
Setting skipOffstate to false in your Finder is an approach. Try this:
expect(find.text("Find me!", skipOffstage: false), findsOneWidget);
Tests should behave as your app would do, otherwise, your tests become useless (since you're not testing the real behavior).
As such, this is not a bug.
You have to manually scroll the ListView inside your tests to make it load more widgets.
This can be done using tester:
final gesture = await tester.startGesture(Offset.zero /* THe position of your listview */ );
// Manual scroll
await gesture.moveBy(const Offset(0, 100));
await tester.pump(); // flush the widget tree
I highly recommend you to pay attention in the "Cartesian plane" of your screen/dragging movement.
Let me explain:
You should use:
await tester.drag(keyCartItemProduct1, Offset(-500.0, 0.0));
However, your "Offset" Command, must obey the same "Cartesian direction" than your Dragging.
2.1) Therefore: (The command Offset uses Cartesian 'directions') - lets see:
a) Left Dragging: Offset(-500.0, 0.0)
b) Right Dragging: Offset(+500.0, 0.0)
c) Up Dragging: Offset(0.0, +500.0)
d) Down Dragging: Offset(0.0, -500.0)
dragUntilVisible helps to scroll Listview or SingleChildScrollView to scroll till the expected widget is visible
final expectedWidget = find.byText("Find me!");
await tester.dragUntilVisible(
expectedWidget, // what you want to find
find.byType(ListView),
// widget you want to scroll
const Offset(0, 500), // delta to move
duration: Duration(seconds: 2));
try this code with skipOffstage set to false, it works fine.
testWidgets('Find widget off of screen', (WidgetTester tester) async {
await tester.pumpWidget(yourScreen);
expect(find.byKey(const Key('widgetname'), skipOffstage: false), findsOneWidget); });