Skip to content
This repository has been archived by the owner on Jan 9, 2024. It is now read-only.

Commit

Permalink
feat: question textfield animation (#75)
Browse files Browse the repository at this point in the history
  • Loading branch information
omartinma authored Dec 7, 2023
1 parent c5e405d commit 5ff80c4
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 40 deletions.
3 changes: 2 additions & 1 deletion lib/home/view/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ class HomeView extends StatelessWidget {
final status = context.select((HomeBloc bloc) => bloc.state.status);

return Scaffold(
backgroundColor: VertexColors.arctic,
backgroundColor:
status.isWelcomeVisible ? VertexColors.arctic : VertexColors.white,
body: Stack(
children: [
if (status.isWelcomeVisible)
Expand Down
4 changes: 3 additions & 1 deletion lib/home/widgets/question_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ class QuestionViewState extends State<QuestionView>
ClipRRect(
child: SlideTransition(
position: _offsetVerticalOut,
child: const SearchBox(),
child: const SearchBox(
shouldAnimate: true,
),
),
),
],
Expand Down
8 changes: 7 additions & 1 deletion lib/home/widgets/search_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class SearchBox extends StatelessWidget {
const SearchBox({this.askAgain = false, super.key});
const SearchBox({
this.shouldAnimate = false,
this.askAgain = false,
super.key,
});

final bool askAgain;
final bool shouldAnimate;

@override
Widget build(BuildContext context) {
Expand All @@ -17,6 +22,7 @@ class SearchBox extends StatelessWidget {
final searchQuery = state.query;
final submittedQuery = state.submittedQuery;
return QuestionInputTextField(
shouldAnimate: shouldAnimate,
shouldDisplayClearTextButton: searchQuery == submittedQuery,
icon: vertexIcons.stars.image(),
hint: l10n.questionHint,
Expand Down
9 changes: 3 additions & 6 deletions packages/app_ui/lib/src/theme/vertex_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,22 @@ class VertexTheme {
hintStyle: VertexTextStyles.bodyLargeRegular
.copyWith(color: VertexColors.mediumGrey),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(
color: VertexColors.googleBlue,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(
color: VertexColors.googleBlue,
width: 2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(
color: VertexColors.googleBlue,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(vertical: 32),
hoverColor: Colors.transparent,
);
}

Expand Down
143 changes: 113 additions & 30 deletions packages/app_ui/lib/src/widgets/question_input_text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class QuestionInputTextField extends StatefulWidget {
required this.onTextUpdated,
required this.onActionPressed,
this.shouldDisplayClearTextButton = false,
this.shouldAnimate = false,
this.text,
super.key,
});
Expand All @@ -40,12 +41,32 @@ class QuestionInputTextField extends StatefulWidget {
/// that clears the text field
final bool shouldDisplayClearTextButton;

/// It indicates if the text field will have animation or not.
/// Defaults to `false`
final bool shouldAnimate;

/// Key to find the animated builder for the text field. Used for testing.
@visibleForTesting
static const Key textFieldAnimatedBuilderKey =
Key('text_field_animated_builder');

/// Key to find the animated builder for the hint. Used for testing.
@visibleForTesting
static const Key hintAnimatedBuilderKey = Key('hint_animated_builder');

@override
State<QuestionInputTextField> createState() => _QuestionTextFieldState();
}

class _QuestionTextFieldState extends State<QuestionInputTextField> {
class _QuestionTextFieldState extends State<QuestionInputTextField>
with TickerProviderStateMixin {
late final TextEditingController _controller;
late final AnimationController _hintAnimationController;
late final AnimationController _textFieldAnimationController;

late Animation<double> _textFieldAnimationSize;
late Animation<double> _hintAnimationPadding;
static const _width = 659.0;

@override
void initState() {
Expand All @@ -54,49 +75,111 @@ class _QuestionTextFieldState extends State<QuestionInputTextField> {
_controller.addListener(() {
widget.onTextUpdated(_controller.text);
});
_textFieldAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_hintAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_hintAnimationPadding =
Tween<double>(begin: 16, end: 600).animate(_hintAnimationController);
_textFieldAnimationSize = Tween<double>(begin: _width, end: 0).animate(
CurvedAnimation(
parent: _textFieldAnimationController,
curve: Curves.decelerate,
),
);

if (widget.shouldAnimate) {
_textFieldAnimationController
..forward()
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_hintAnimationController.forward();
}
});
}
}

@override
void dispose() {
_controller.dispose();
_textFieldAnimationController.dispose();
_hintAnimationController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Container(
constraints: const BoxConstraints(maxWidth: 659),
child: TextField(
controller: _controller,
style: textTheme.bodyMedium?.copyWith(
color: VertexColors.flutterNavy,
),
autofillHints: null,
onSubmitted: (_) => widget.onActionPressed(),
decoration: InputDecoration(
filled: true,
fillColor: VertexColors.arctic,
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 12),
child: widget.icon,
constraints: const BoxConstraints(maxWidth: _width, maxHeight: 100),
child: Stack(
fit: StackFit.expand,
children: [
Align(
child: TextField(
controller: _controller,
style: textTheme.bodyMedium?.copyWith(
color: VertexColors.flutterNavy,
),
autofillHints: null,
onSubmitted: (_) => widget.onActionPressed(),
decoration: InputDecoration(
filled: true,
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 12),
child: widget.icon,
),
hintText: widget.hint,
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 12),
child: widget.shouldDisplayClearTextButton
? IconButton(
onPressed: () {
_controller.clear();
},
icon: const Icon(Icons.close),
)
: PrimaryCTA(
label: widget.actionText,
onPressed: () => widget.onActionPressed(),
),
),
),
),
),
hintText: widget.hint,
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 12),
child: widget.shouldDisplayClearTextButton
? IconButton(
onPressed: () {
_controller.clear();
},
icon: const Icon(Icons.close),
)
: PrimaryCTA(
label: widget.actionText,
onPressed: () => widget.onActionPressed(),
if (widget.shouldAnimate) ...[
Align(
alignment: Alignment.centerRight,
child: AnimatedBuilder(
key: QuestionInputTextField.textFieldAnimatedBuilderKey,
animation: _textFieldAnimationController,
builder: (_, __) => Container(
color: VertexColors.white,
width: _textFieldAnimationSize.value,
),
),
),
Align(
child: AnimatedBuilder(
key: QuestionInputTextField.hintAnimatedBuilderKey,
animation: _hintAnimationController,
builder: (_, __) => Container(
color: VertexColors.white,
margin: EdgeInsets.only(
top: 32,
bottom: 32,
right: 120,
left: _hintAnimationPadding.value,
),
),
),
),
),
),
],
],
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import '../helpers/helpers.dart';

void main() {
group('QuestionInputTextField', () {
testWidgets('renders correctly', (tester) async {
testWidgets('renders correctly without animation', (tester) async {
await tester.pumpApp(
Material(
child: QuestionInputTextField(
Expand All @@ -23,6 +23,42 @@ void main() {
expect(find.byType(PrimaryCTA), findsOneWidget);
});

testWidgets('renders correctly with animation', (tester) async {
await tester.pumpApp(
Material(
child: QuestionInputTextField(
icon: SizedBox.shrink(),
hint: 'hint',
actionText: 'actionText',
onActionPressed: () {},
onTextUpdated: (_) {},
shouldAnimate: true,
),
),
);

expect(find.text('hint'), findsOneWidget);
expect(find.byType(PrimaryCTA), findsOneWidget);
final textFieldAnimationController = tester
.widget<AnimatedBuilder>(
find.byKey(QuestionInputTextField.textFieldAnimatedBuilderKey),
)
.animation as AnimationController;
final hintAnimationController = tester
.widget<AnimatedBuilder>(
find.byKey(QuestionInputTextField.hintAnimatedBuilderKey),
)
.animation as AnimationController;
expect(textFieldAnimationController.status, AnimationStatus.forward);
expect(hintAnimationController.status, AnimationStatus.dismissed);
await tester.pump(Duration(milliseconds: 1201));
expect(textFieldAnimationController.status, AnimationStatus.completed);
expect(hintAnimationController.status, AnimationStatus.forward);
await tester.pump(Duration(milliseconds: 601));
expect(textFieldAnimationController.status, AnimationStatus.completed);
expect(hintAnimationController.status, AnimationStatus.completed);
});

testWidgets('calls onTextUpdated typing on the text field', (tester) async {
var text = '';
await tester.pumpApp(
Expand Down

0 comments on commit 5ff80c4

Please sign in to comment.