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

feat: question textfield animation #75

Merged
merged 6 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(
RuiMiguel marked this conversation as resolved.
Show resolved Hide resolved
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