TextMagnifier in flutter - flutter

Are there any packages available in flutter to implement TextMagnifier?
I need to magnify the text while editing the text by using TextFormField (like in the iphone).

You can use magnifierConfiguration property in your TextField
Flutter has added support for text magnification from its 3.7 Release
You can refer this example below:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() => runApp(const MyApp(text: 'Hello world!'));
class MyApp extends StatelessWidget {
const MyApp({
super.key,
this.textDirection = TextDirection.ltr,
required this.text,
});
final TextDirection textDirection;
final String text;
static const Size loupeSize = Size(200, 200);
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 48.0),
child: Center(
child: TextField(
textDirection: textDirection,
// Create a custom magnifier configuration that
// this `TextField` will use to build a magnifier with.
magnifierConfiguration: TextMagnifierConfiguration(
magnifierBuilder:
(_, __, ValueNotifier<MagnifierInfo> magnifierInfo) =>
CustomMagnifier(
magnifierInfo: magnifierInfo,
),
),
controller: TextEditingController(text: text),
),
),
),
),
);
}
}
class CustomMagnifier extends StatelessWidget {
const CustomMagnifier({super.key, required this.magnifierInfo});
static const Size magnifierSize = Size(200, 200);
// This magnifier will consume some text data and position itself
// based on the info in the magnifier.
final ValueNotifier<MagnifierInfo> magnifierInfo;
#override
Widget build(BuildContext context) {
// Use a value listenable builder because we want to rebuild
// every time the text selection info changes.
// `CustomMagnifier` could also be a `StatefulWidget` and call `setState`
// when `magnifierInfo` updates. This would be useful for more complex
// positioning cases.
return ValueListenableBuilder<MagnifierInfo>(
valueListenable: magnifierInfo,
builder: (BuildContext context, MagnifierInfo currentMagnifierInfo, _) {
// We want to position the magnifier at the global position of the gesture.
Offset magnifierPosition = currentMagnifierInfo.globalGesturePosition;
// You may use the `MagnifierInfo` however you'd like:
// In this case, we make sure the magnifier never goes out of the current line bounds.
magnifierPosition = Offset(
clampDouble(
magnifierPosition.dx,
currentMagnifierInfo.currentLineBoundaries.left,
currentMagnifierInfo.currentLineBoundaries.right,
),
clampDouble(
magnifierPosition.dy,
currentMagnifierInfo.currentLineBoundaries.top,
currentMagnifierInfo.currentLineBoundaries.bottom,
),
);
// Finally, align the magnifier to the bottom center. The inital anchor is
// the top left, so subtract bottom center alignment.
magnifierPosition -= Alignment.bottomCenter.alongSize(magnifierSize);
return Positioned(
left: magnifierPosition.dx,
top: magnifierPosition.dy,
child: RawMagnifier(
magnificationScale: 2,
// The focal point starts at the center of the magnifier.
// We probably want to point below the magnifier, so
// offset the focal point by half the magnifier height.
focalPointOffset: Offset(0, magnifierSize.height / 2),
// Decorate it however we'd like!
decoration: const MagnifierDecoration(
shape: StarBorder(
side: BorderSide(
color: Colors.green,
width: 2,
),
),
),
size: magnifierSize,
),
);
});
}
}

Related

How to get the exact size of PopupMenuButton (or any widget)?

I need to get the PopupMenuButton's size because it has a property offset which controls where the dropdown menu is rendered on the screen, and I want this one to be rendered such that the top left of the drop down menu is aligned with the bottom left of the PopupMenuButton (see image below).
My approach now is this:
extension TextExtension on Text {
/// Calculates the size of the text inside this text widget.
/// note: this method supposes ltr direction of text, which is not always true, but it doesn't affect the size that much, so
/// keep in mind that the size returned may be approximate in some cases.
/// The text inside this widget must be non-null before calling this method.
Size getSize({TextDirection? textDirection}) {
TextPainter tp = TextPainter(
text: TextSpan(text: data),
textDirection: textDirection ?? TextDirection.ltr)
..layout();
return tp.size;
}
}
And then when I define the PopupMenuButton, I do this:
Widget _dropDownMenu({
required BuildContext context,
required String title,
required List<PopupMenuItem> items,
}) {
final text = Text(
title,
style: Theme.of(context).textTheme.bodyMedium,
);
final textSize = text.size;
return PopupMenuButton(
child: text,
itemBuilder: (context) => items,
offset: Offset(0, textSize.height),
);
}
It works, but I don't like it. I think there must be a better way to do this.
This is how it looks like right now:
I tried LayoutBuilder, but it is returning infinite width constraints.
Is there a more clean way of doing this?
It seems there is no other way except the approach I mentioned in the question, or to modify the source code of the PopupMenuButton to make it accept an OffsetBuilder as pskink mentioned. This can be done like this (and there is full working example below):
go to the PopupMenuButton source code and copy it all into a new file custom_popup_menu.dart (in this new file just remove all the imports and import them again as suggested by the IDE to fix them)
add this to anywhere top level in the file: Offset _defaultOffsetBuilder(Size size) => Offset.zero;
inside the PopupMenuButton class replace final Offset offset with
/// The button size will be passed to this function to get the offset applied
/// to the Popup Menu when it is open. The top left of the [PopupMenuButton] is considered
/// as the origin of the coordinate system of this offset.
///
/// When not set, the Popup Menu Button will be positioned directly next to
/// the button that was used to create it.
final Offset Function(Size) offsetBuilder;
inside the constructor of this class replace this.offset with this.offsetBuilder = _defaultOffsetBuilder,
in the showButtonMenu method of PopupMenuButtonState class, replace
Rect.fromPoints(
button.localToGlobal(widget.offset, ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero) + widget.offset,
ancestor: overlay),
),
Offset.zero & overlay.size,
);
with
final offset = widget.offsetBuilder(button.size);
final RelativeRect position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(offset, ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero) + offset,
ancestor: overlay),
),
Offset.zero & overlay.size,
);
Full Working Example:
... (imports)
import 'custom_popup_menu.dart' as pm;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) => const MaterialApp(
debugShowCheckedModeBanner: false,
home: HomePage2(),
);
}
class HomePage2 extends StatelessWidget {
const HomePage2({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) => Scaffold(
body: Align(
alignment: const Alignment(0, -0.8),
child: Container(
decoration: BoxDecoration(border: Border.all(width: 2.0)),
child: pm.PopupMenuButton<String>(
child: const Text(
'Press Me',
style: TextStyle(color: Colors.black, fontSize: 50),
),
itemBuilder: (context) => [
_buildPopupMenuItem(),
_buildPopupMenuItem(),
_buildPopupMenuItem(),
],
color: Colors.red,
offsetBuilder: (buttonSize) => Offset(0, buttonSize.height),
),
),
),
);
pm.PopupMenuItem<String> _buildPopupMenuItem() {
return pm.PopupMenuItem(
child: Text(
'Press Me ${Random().nextInt(100)}',
style: const TextStyle(color: Colors.black, fontSize: 50),
),
onTap: () {},
);
}
}

Flutter - Show Row on extra scroll - Top of column (Like Whatsapp Archived Chats)

I want to put a row on top of a column, which will not be visible initially. It will only be visible when the scroll offset of the SingleChildScrollview is negative.
In other words, only if the user scrolls further than normal (downwards motion) will this Row show. This is an example in Whatsapp. The "Search Box" widget is not shown initially, only if you scroll up, and disappears once scroll is downwards.
UPDATE
Using #Lulupointu's answer, I was able to get to this:
The top widget shows and hides on scroll with a smooth animation.
#Hooshyar's answer also works but is less smooth and uses a different method.
Using singleChildScrollView here is what I came up with using s NotificationListener() widget, there are other solutions but this one is the simplest one:
have a bool to determine Container visibility:
bool shouldIShowTheUpperThing = false;
the have your SingleChildScrollView() wrapped with a NotificationListener() :
NotificationListener(
child: SingleChildScrollView(
child: Column(
children: [
shouldIShowTheUpperThing == false ? Row(
children: [
Container(height: 0,),
],
) : Row(
children: [
Expanded(child: Container(color: Colors.red , height: 100 , child: Text('the hidden box'),)),
],
),
Container(
padding: EdgeInsets.all(130),
child: Text('data'),
color: Colors.blueGrey,
),
Container(
padding: EdgeInsets.all(130),
child: Text('data'),
color: Colors.blueAccent,
),
Container(
padding: EdgeInsets.all(130),
child: Text('data'),
color: Colors.amber,
),
Container(
padding: EdgeInsets.all(130),
child: Text('data'),
color: Colors.black12,
),
Container(
padding: EdgeInsets.all(130),
child: Text('data'),
),
],
),
),
onNotification: (t) {
if (t is ScrollNotification) {
if (t.metrics.pixels < 1.0) {
setState(() {
shouldIShowTheUpperThing = true;
});
}
}
return true;
});
}
Since this is a unusual scroll effect, if you want it to look good I think you need to use slivers.
Implementation
What I did was copy paste SliverToBoxAdapter and modify it.
Features:
The child is hidden when the widget is first loaded
The child is snappy
The child hide when the user scroll down again
Limitations:
If there are not enough children in the CustomScrollView to over-scroll, the child will always be visible and a weird scroll effect will appear on startup
class SliverHidedHeader extends SingleChildRenderObjectWidget {
const SliverHidedHeader({
Key? key,
required Widget child,
}) : super(key: key, child: child);
#override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverHidedHeader(context: context);
}
}
class RenderSliverHidedHeader extends RenderSliverSingleBoxAdapter {
RenderSliverHidedHeader({
required BuildContext context,
RenderBox? child,
}) : _context = context,
super(child: child);
/// Whether we need to apply a correction to the scroll
/// offset during the next layout
///
///
/// This is useful to avoid the viewport to jump when we
/// insert/remove the child.
///
/// If [showChild] is true, its an insert
/// If [showChild] is false, its a removal
bool _correctScrollOffsetNextLayout = true;
/// Whether [child] should be shown
///
///
/// This is used to hide the child when the user scrolls down
bool _showChild = true;
/// The context is used to get the [Scrollable]
BuildContext _context;
#override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
final SliverConstraints constraints = this.constraints;
child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final double childExtent;
switch (constraints.axis) {
case Axis.horizontal:
childExtent = child!.size.width;
break;
case Axis.vertical:
childExtent = child!.size.height;
break;
}
final double paintedChildSize =
calculatePaintOffset(constraints, from: 0.0, to: childExtent);
final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: childExtent);
assert(paintedChildSize.isFinite);
assert(paintedChildSize >= 0.0);
// Here are the few custom lines, which use [scrollOffsetCorrection]
// to remove the child size
//
// Note that this should only be called for correction linked with the
// insertion (NOT the removal)
if (_correctScrollOffsetNextLayout) {
geometry = SliverGeometry(scrollOffsetCorrection: childExtent);
_correctScrollOffsetNextLayout = false;
return;
}
// Subscribe a listener to the scroll notifier
// which will snap if needed
_manageSnapEffect(
childExtent: childExtent,
paintedChildSize: paintedChildSize,
);
// Subscribe a listener to the scroll notifier
// which hide the child if needed
_manageInsertChild(
childExtent: childExtent,
paintedChildSize: paintedChildSize,
);
geometry = SliverGeometry(
scrollExtent: childExtent,
paintExtent: paintedChildSize,
paintOrigin: _showChild ? 0 : -paintedChildSize,
layoutExtent: _showChild ? null : 0,
cacheExtent: cacheExtent,
maxPaintExtent: childExtent,
hitTestExtent: paintedChildSize,
hasVisualOverflow:
childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
);
setChildParentData(child!, constraints, geometry!);
}
/// Override to remove the listeners if needed
#override
void dispose() {
final _scrollPosition = Scrollable.of(_context)!.position;
if (_subscribedSnapScrollNotifierListener != null) {
_scrollPosition.isScrollingNotifier
.removeListener(_subscribedSnapScrollNotifierListener!);
}
if (_subscribedInsertChildScrollNotifierListener != null) {
_scrollPosition.isScrollingNotifier
.removeListener(_subscribedInsertChildScrollNotifierListener!);
}
super.dispose();
}
/// The listener which will snap if needed
///
///
/// We store it to be able to remove it before subscribing
/// a new one
void Function()? _subscribedSnapScrollNotifierListener;
/// Handles the subscription and removal of subscription to
/// the scrollable position notifier which are responsible
/// for the snapping effect
///
///
/// This must be called at each [performLayout] to ensure that the
/// [childExtent] and [paintedChildSize] parameters are up to date
_manageSnapEffect({
required double childExtent,
required double paintedChildSize,
}) {
final _scrollPosition = Scrollable.of(_context)!.position;
// If we were subscribed with previous value, remove the subscription
if (_subscribedSnapScrollNotifierListener != null) {
_scrollPosition.isScrollingNotifier
.removeListener(_subscribedSnapScrollNotifierListener!);
}
// We store the subscription to be able to remove it
_subscribedSnapScrollNotifierListener = () => _snapScrollNotifierListener(
childExtent: childExtent,
paintedChildSize: paintedChildSize,
);
_scrollPosition.isScrollingNotifier.addListener(_subscribedSnapScrollNotifierListener!);
}
/// Snaps if the user just stopped scrolling and the child is
/// partially visible
void _snapScrollNotifierListener({
required double childExtent,
required double paintedChildSize,
}) {
final _scrollPosition = Scrollable.of(_context)!.position;
// Whether the user is currently idle (i.e not scrolling)
//
// We don't check _scrollPosition.activity.isScrolling or
// _scrollPosition.isScrollingNotifier.value because even if
// the user is holding still we don't want to start animating
//
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
final isIdle = _scrollPosition.activity is IdleScrollActivity;
// Whether at least part of the child is visible
final isChildVisible = paintedChildSize > 0;
if (isIdle && isChildVisible) {
// If more than half is visible, snap to see everything
if (paintedChildSize >= childExtent / 2 && paintedChildSize != childExtent) {
_scrollPosition.animateTo(
0,
duration: Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
// If less than half is visible, snap to hide
else if (paintedChildSize < childExtent / 2 && paintedChildSize != 0) {
_scrollPosition.animateTo(
childExtent,
duration: Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
}
/// The listener which will hide the child if needed
///
///
/// We store it to be able to remove it before subscribing
/// a new one
void Function()? _subscribedInsertChildScrollNotifierListener;
/// Handles the subscription and removal of subscription to
/// the scrollable position notifier which are responsible
/// for inserting/removing the child if needed
///
///
/// This must be called at each [performLayout] to ensure that the
/// [childExtent] and [paintedChildSize] parameters are up to date
void _manageInsertChild({
required double childExtent,
required double paintedChildSize,
}) {
final _scrollPosition = Scrollable.of(_context)!.position;
// If we were subscribed with previous value, remove the subscription
if (_subscribedInsertChildScrollNotifierListener != null) {
_scrollPosition.isScrollingNotifier
.removeListener(_subscribedInsertChildScrollNotifierListener!);
}
// We store the subscription to be able to remove it
_subscribedInsertChildScrollNotifierListener = () => _insertChildScrollNotifierListener(
childExtent: childExtent,
paintedChildSize: paintedChildSize,
);
_scrollPosition.isScrollingNotifier
.addListener(_subscribedInsertChildScrollNotifierListener!);
}
/// When [ScrollPosition.isScrollingNotifier] fires:
/// - If the viewport is at the top and the child is not visible,
/// ^ insert the child
/// - If the viewport is NOT at the top and the child is NOT visible,
/// ^ remove the child
void _insertChildScrollNotifierListener({
required double childExtent,
required double paintedChildSize,
}) {
final _scrollPosition = Scrollable.of(_context)!.position;
final isScrolling = _scrollPosition.isScrollingNotifier.value;
// If the user is still scrolling, do nothing
if (isScrolling) {
return;
}
final scrollOffset = _scrollPosition.pixels;
// If the viewport is at the top and the child is not visible,
// insert the child
//
// We use 0.1 as a small value in case the user is nearly scrolled
// all the way up
if (!_showChild && scrollOffset <= 0.1) {
_showChild = true;
_correctScrollOffsetNextLayout = true;
markNeedsLayout();
}
// There is sometimes an issue with [ClampingScrollPhysics] where
// the child is NOT shown but the scroll offset still includes [childExtent]
//
// There is no why to detect it but we always insert the child when all
// this conditions are united.
// This means that if a user as [ClampingScrollPhysics] and stops scrolling
// exactly at [childExtent], the child will be wrongfully inserted. However
// this seems a small price to pay to avoid the issue.
if (_scrollPosition.physics.containsScrollPhysicsOfType<ClampingScrollPhysics>()) {
if (!_showChild && scrollOffset == childExtent) {
_showChild = true;
markNeedsLayout();
}
}
// If the viewport is NOT at the top and the child is NOT visible,
// remove the child
if (_showChild && scrollOffset > childExtent) {
_showChild = false;
markNeedsLayout();
// We don't have to correct the scroll offset here, no idea why
}
}
}
/// An extension on [ScrollPhysics] to check if it or its
/// parent are the given [ScrollPhysics]
extension _ScrollPhysicsExtension on ScrollPhysics {
/// Check the type of this [ScrollPhysics] and its parents and return
/// true if any is of type [T]
bool containsScrollPhysicsOfType<T extends ScrollPhysics>() {
return this is T || (parent?.containsScrollPhysicsOfType<T>() ?? false);
}
}
How to use it
Use it at the top of your list of slivers:
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(MaterialApp(home: MyStatefulWidget()));
}
class MyStatefulWidget extends StatefulWidget {
const MyStatefulWidget({Key? key}) : super(key: key);
#override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
#override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverHidedHeader(
child: Container(
child: Center(child: Text('SliverAppBar')),
height: 100,
color: Colors.redAccent,
),
),
const SliverToBoxAdapter(
child: SizedBox(
height: 20,
child: Center(
child: Text('Scroll to see the SliverAppBar in effect.'),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return Container(
color: index.isOdd ? Colors.white : Colors.black12,
height: 100.0,
child: Center(
child: Text('$index', textScaleFactor: 5),
),
);
},
childCount: 20,
),
),
],
),
),
);
}
}
Other resource
Check out the pull_to_refresh package if you want a different approach to solve this issue. Beware that their code is quite complex since they also have the auto-hide feature implemented, but it's worth a look if you have time.
Solve the limitation
I'm not sure the limitation can be solved using this approach. The issue is that, for performance reason, the sliver does not know anything about the one bellow it, meaning that it's quite hard to even knowing when we are in the problematic case, let alone handle it.
try SliverAppBar and set floating and snap named params to true
class MyStatefulWidget extends StatefulWidget {
const MyStatefulWidget({Key? key}) : super(key: key);
#override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
#override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(
pinned: true,
snap: true,
floating: true,
expandedHeight: 160.0,
flexibleSpace: FlexibleSpaceBar(
title: Text('SliverAppBar'),
background: FlutterLogo(),
),
),
const SliverToBoxAdapter(
child: SizedBox(
height: 20,
child: Center(
child: Text('Scroll to see the SliverAppBar in effect.'),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return Container(
color: index.isOdd ? Colors.white : Colors.black12,
height: 100.0,
child: Center(
child: Text('$index', textScaleFactor: 5),
),
);
},
childCount: 20,
),
),
],
),
);
}
}

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.

Widgets sliding from outside the screen in Flutter ? Similar to Android 8 app drawer

I am writing a flashcard app (an extension to the open source AnkiDroid app) in Flutter. The basic workflow is: the app shows me a question and I can reveal the answer. The gesture I want in order to reveal the answer is similar to the Android 8 swipe up from the bottom icon row to reveal the app drawer. A fast swipe (or fling in the android terminology?) can reveal the app list, but a drawn out, slow swipe can control the motion of the apps drawer.
My questions are the following:
What is the proper way to have widgets slide in from outside the screen ? Flutter complains that I'm trying to display widgets outside the screen, suggests I use ClipRect, but I haven't found a way for ClipRect to only display something the size of the screen (it seems to adjust itself to the size of the child)
What is the recommended layout for what I want to do ? Currently I have the question and answer in a Column, and in order to center the question initially and hide the question, I modify the padding. It feels like a bit of a hack.
Is there a helper library that can help me achieve the exact swipe/fling motion that I'm after? It needs to take into account momentum and position in order for the motion to feel just as natural as the android 8 app drawer.
Thank you for any suggestions you may have.
Here are the screens I have so far:
Question screen
Answer screen (after swiping up)
And here's the code:
import 'package:flutter/material.dart';
import 'dart:math';
// Uncomment lines 7 and 10 to view the visual layout at runtime.
//import 'package:flutter/rendering.dart' show debugPaintSizeEnabled;
void main() {
//debugPaintSizeEnabled = true;
runApp(MyApp());
}
/*
* travel around the world
* 環遊世界
* wàan jàu sâi gâai
*/
class Card extends StatefulWidget {
#override
createState() => CardState();
}
class CardState extends State<Card> with SingleTickerProviderStateMixin {
var _dragStartOffset;
Animation<double> questionAnimation;
Animation<double> answerAnimation;
Animation<double> opacityAnimation;
AnimationController controller;
initState() {
super.initState();
controller = AnimationController(duration: const Duration(milliseconds: 250), vsync: this);
questionAnimation = Tween(begin: 250.0, end: 150.0).animate(controller)
..addListener(() {
setState(() {
// the state that has changed here is the animation object’s value
});
});
answerAnimation = Tween(begin: 200.0, end: 32.0).animate(controller)
..addListener(() {
setState(() {
// the state that has changed here is the animation object’s value
});
});
opacityAnimation = Tween(begin: 0.0, end: 1.0).animate(controller)
..addListener(() {
setState(() {
// the state that has changed here is the animation object’s value
});
});
}
#override
Widget build(BuildContext context) {
Widget question = Container(
padding: EdgeInsets.only(top: questionAnimation.value),
child: Center (
child: Text(
"travel around the world",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 48.0,
),
textAlign: TextAlign.center,
)
),
);
Widget answer = Container(
padding: EdgeInsets.only(top: answerAnimation.value),
child: Opacity(
opacity: opacityAnimation.value,
child: Text(
"wàan jàu sâi gâai 環遊世界",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 48.0,
),
textAlign: TextAlign.center,
)
)
);
var children = [question, answer];
var child = GestureDetector(
onTap: () {
controller.reset();
},
onVerticalDragUpdate: (data) {
// print(data);
var currentOffset = data.globalPosition;
var travel = _dragStartOffset - currentOffset;
// print(travel);
if(travel.dy <0 )
{
return;
}
// cannot be lower than zero
var travelY = max<double>(0.0, travel.dy);
// cannot be higher than 100
travelY = min<double>(200.0, travelY);
var animationPosition = travelY / 200.0;
controller.value = animationPosition;
},
onVerticalDragEnd: (data) {
if(controller.value > 0.50) {
// make the animation continue on its own
controller.forward();
} else {
// go back the other way
controller.reverse();
}
},
onVerticalDragStart: (data) {
//print(data);
_dragStartOffset = data.globalPosition;
},
child: Scaffold(
appBar: AppBar(
title: Text('AnkiReview'),
),
body: Container(
child:Column(
children: children,
)
),
)
);
return child;
}
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: Card(),
);
}
}
I figured out one solution. It involves a Column, the top is just a Container with the question, but the bottom is a PageView which has a blank first page. The user can slide up to reveal the answer.
It solves the clipping issue, and also the physics issue, because PageView has built-in physics and snapping, which would otherwise not be trivial to build (I would probably have to use a CustomScrollView).
code:
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'dart:math';
// Uncomment lines 7 and 10 to view the visual layout at runtime.
//import 'package:flutter/rendering.dart' show debugPaintSizeEnabled;
void main() {
//debugPaintSizeEnabled = true;
runApp(MyApp());
}
/*
* travel around the world
* 環遊世界
* wàan jàu sâi gâai
*/
class Card extends StatefulWidget {
#override
createState() => CardState();
}
class CardState extends State<Card> with SingleTickerProviderStateMixin {
var _dragStartOffset;
var _fontSize = 48.0;
static const _padding = 28.0;
initState() {
super.initState();
}
#override
Widget build(BuildContext context) {
var questionText = Text(
"travel around the world",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: _fontSize,
),
textAlign: TextAlign.center,
);
var answerText = Text(
"wàan jàu sâi gâai 環遊世界",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: _fontSize,
),
textAlign: TextAlign.center
);
Widget question = Container(
padding: EdgeInsets.only(bottom: _padding),
alignment: Alignment.bottomCenter,
child: questionText
);
Widget answer = Container(
padding: EdgeInsets.only(top: _padding),
alignment: Alignment.topCenter,
child: answerText
);
var card = Column(
children: [
Expanded(
child: question,
),
Expanded(
child: PageView(
scrollDirection: Axis.vertical,
children: [
Container(),
answer
]
)
)
]
);
return card;
}
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: Scaffold(
appBar: AppBar(
title: Text('AnkiReview'),
),
body: Container(
child:Card()
),
),
);
}
}

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: