Skip to content
Open
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
1 change: 1 addition & 0 deletions app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop)
### Added
### Changed
- [Library] `tab bar component`, update the animation of the `selected tab indicator` ([#633](https://github.com/Orange-OpenSource/ouds-flutter/issues/633))
- [Library] update `Phone number input` component to v1.3 ([#690](https://github.com/Orange-OpenSource/ouds-flutter/issues/690))
- [Library] update `tag` component to v1.5 ([#694](https://github.com/Orange-OpenSource/ouds-flutter/issues/694))
- [Library] update `input tag` component to v1.2 ([#695](https://github.com/Orange-OpenSource/ouds-flutter/issues/695))
Expand Down
1 change: 1 addition & 0 deletions ouds_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop)
### Added
### Changed
- [Library] `tab bar component`, update the animation of the `selected tab indicator` ([#633](https://github.com/Orange-OpenSource/ouds-flutter/issues/633))
- [Library] update `Phone number input` component to v1.3 ([#690](https://github.com/Orange-OpenSource/ouds-flutter/issues/690))
- [Library] update `tag` component to v1.5 ([#694](https://github.com/Orange-OpenSource/ouds-flutter/issues/694))
- [Library] update `input tag` component to v1.2 ([#695](https://github.com/Orange-OpenSource/ouds-flutter/issues/695))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*
* // Software Name: OUDS Flutter
* // SPDX-FileCopyrightText: Copyright (c) Orange SA
* // SPDX-License-Identifier: MIT
* //
* // This software is distributed under the MIT license,
* // the text of which is available at https://opensource.org/license/MIT/
* // or see the "LICENSE" file for more details.
* //
* // Software description: Flutter library of reusable graphical components
* //
*/

import 'package:flutter/material.dart';
import 'package:ouds_theme_contract/theme/tokens/components/ouds_bar_tokens.dart';

/// A custom painter for drawing an animated navigation bar indicator.
///
/// The indicator expands from the center of the tab outwards to its edges,
/// creating a smooth animation effect when a tab becomes selected.
class _OudsIndicatorPainter extends CustomPainter {
/// The animation value (0.0 to 1.0) controlling the expansion from center.
final double animationValue;

/// The color of the indicator line.
final Color color;

/// The height (thickness) of the indicator line.
final double thickness;

/// The width of the tab (used to determine expansion limits).
final double tabWidth;

/// The border radius of the indicator.
final double borderRadius;

_OudsIndicatorPainter({
required this.animationValue,
required this.color,
required this.thickness,
required this.tabWidth,
required this.borderRadius,
});

@override
void paint(Canvas canvas, Size size) {
// Calculate the expansion: starts from center and expands to edges
final centerX = tabWidth / 2;
final maxExpansion = tabWidth / 2;
final currentExpansion = maxExpansion * animationValue;

// Starting point (left edge) and ending point (right edge) of the indicator
final startX = centerX - currentExpansion;
final endX = centerX + currentExpansion;
final rectWidth = endX - startX;

// Only draw if width is positive
if (rectWidth > 0) {
final rect = Rect.fromLTWH(startX, 0, rectWidth, thickness);
final rrect = RRect.fromRectAndRadius(
rect,
Radius.circular(borderRadius),
);

final paint = Paint()
..color = color
..style = PaintingStyle.fill;

canvas.drawRRect(rrect, paint);
}
}

@override
bool shouldRepaint(covariant _OudsIndicatorPainter oldDelegate) {
return oldDelegate.animationValue != animationValue ||
oldDelegate.color != color ||
oldDelegate.thickness != thickness ||
oldDelegate.tabWidth != tabWidth ||
oldDelegate.borderRadius != borderRadius;
}
}

/// A widget that renders an animated indicator with expansion from center animation.
class OudsAnimatedIndicator extends StatefulWidget {
/// Whether the indicator should be visible and animated.
final bool isSelected;

/// The color of the indicator.
final Color color;

/// The height (thickness) of the indicator line.
final double thickness;

/// The width of the tab containing this indicator.
final double tabWidth;

/// The border radius of the indicator.
final double borderRadius;

/// The duration of the animation.
final Duration animationDuration;

const OudsAnimatedIndicator({
super.key,
required this.isSelected,
required this.color,
required this.thickness,
required this.tabWidth,
required this.borderRadius,
this.animationDuration = const Duration(milliseconds: 300),
});

@override
State<OudsAnimatedIndicator> createState() => _OudsAnimatedIndicatorState();
}

class _OudsAnimatedIndicatorState extends State<OudsAnimatedIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;

@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);

_animation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);

// Start animation if initially selected
if (widget.isSelected) {
_animationController.forward();
}
}

@override
void didUpdateWidget(covariant OudsAnimatedIndicator oldWidget) {
super.didUpdateWidget(oldWidget);

if (widget.isSelected != oldWidget.isSelected) {
if (widget.isSelected) {
// Forward animation when selecting
_animationController.forward();
} else {
// Reverse animation when deselecting
_animationController.reverse();
}
}
}

@override
void dispose() {
_animationController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return SizedBox(
height: widget.thickness,
width: widget.tabWidth,
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return CustomPaint(
painter: _OudsIndicatorPainter(
animationValue: _animation.value,
color: widget.color,
thickness: widget.thickness,
tabWidth: widget.tabWidth,
borderRadius: widget.borderRadius,
),
);
},
),
);
}
}

/// Extension to easily access indicator animation properties from bar tokens.
extension OudsBarTokensIndicatorExtension on OudsBarTokens {
/// Gets the animation duration for the indicator.
Duration getIndicatorAnimationDuration() {
return const Duration(milliseconds: 300);
}
}
69 changes: 48 additions & 21 deletions ouds_core/lib/components/navigation/ouds_navigation_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,24 +126,39 @@ class _OudsNavigationBarState extends State<OudsNavigationBar> {
@override
void initState() {
super.initState();
_selectedIndex = widget.selectedIndex.clamp(0, widget.destinations.length - 1);
_selectedIndex = widget.selectedIndex.clamp(
0,
widget.destinations.length - 1,
);
}

/// Updates the selected index if [currentIndex] changes.
@override
void didUpdateWidget(covariant OudsNavigationBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selectedIndex != oldWidget.selectedIndex) {
_selectedIndex = widget.selectedIndex.clamp(0, widget.destinations.length - 1);
_selectedIndex = widget.selectedIndex.clamp(
0,
widget.destinations.length - 1,
);
}
}

/// Builds the navigation bar with dynamic label and icon colors and a custom indicator shape.
@override
Widget build(BuildContext context) {
final interactionModelHover = OudsInheritedInteractionModel.of(context, InteractionAspect.hover);
final interactionModelPressed = OudsInheritedInteractionModel.of(context, InteractionAspect.pressed);
final interactionModelFocused = OudsInheritedInteractionModel.of(context, InteractionAspect.focused);
final interactionModelHover = OudsInheritedInteractionModel.of(
context,
InteractionAspect.hover,
);
final interactionModelPressed = OudsInheritedInteractionModel.of(
context,
InteractionAspect.pressed,
);
final interactionModelFocused = OudsInheritedInteractionModel.of(
context,
InteractionAspect.focused,
);

final isHovered = interactionModelHover?.state.isHovered ?? false;
final isPressed = interactionModelPressed?.state.isPressed ?? false;
Expand All @@ -158,8 +173,12 @@ class _OudsNavigationBarState extends State<OudsNavigationBar> {

final barControlState = barStateDeterminer.determineControlState();
final navigationBarModifier = OudsNavigationBarStatusModifier(context);
final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier(context);
final navigationBarBorderModifier = OudsNavigationBarBorderModifier(context);
final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier(
context,
);
final navigationBarBorderModifier = OudsNavigationBarBorderModifier(
context,
);

final safeIndex = _selectedIndex.clamp(0, widget.destinations.length - 1);

Expand All @@ -180,22 +199,30 @@ class _OudsNavigationBarState extends State<OudsNavigationBar> {
),
// `overlayColor` is the transient ink overlay used for interaction feedback (pressed/hovered/focused),
// resolved per destination via `WidgetState`.
overlayColor: WidgetStateProperty.resolveWith<Color>(
(states) {
final isSelected = states.contains(WidgetState.selected);
return navigationBarModifier.getMaterialIndicatorBarColor(barControlState, isSelected);
},
overlayColor: WidgetStateProperty.resolveWith<Color>((states) {
final isSelected = states.contains(WidgetState.selected);
return navigationBarModifier.getMaterialIndicatorBarColor(
barControlState,
isSelected,
);
}),
backgroundColor: navigationBarBgModifier.getBackgroundColor(
widget.translucent,
),
backgroundColor: navigationBarBgModifier.getBackgroundColor(widget.translucent),
// Label text style resolved per destination via `WidgetState` (at minimum selected/unselected).
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle>(
(states) {
final isSelected = states.contains(WidgetState.selected);
return OudsTheme.of(context).typographyTokens.typeLabelDefaultMedium(context).copyWith(
color: navigationBarModifier.getTextIconItemColor(barControlState, isSelected),
);
},
),
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle>((
states,
) {
final isSelected = states.contains(WidgetState.selected);
return TextStyle(
color: navigationBarModifier.getTextIconItemColor(
barControlState,
isSelected,
),
overflow: TextOverflow.ellipsis,
fontFamily: OudsTheme.of(context).fontFamily,
).copyWith(fontSize: 12);
}),
destinations: List.generate(
widget.destinations.length,
(index) => widget.destinations[index].toNavigationDestination(
Expand Down
Loading
Loading