← Back to Blog

Flutter RelativePositionedTransition Localization: Proportional Animations for Multilingual Apps

flutterrelativepositionedtransitionanimationpositioninglocalizationresponsive

Flutter RelativePositionedTransition Localization: Proportional Animations for Multilingual Apps

RelativePositionedTransition provides explicit control over position animations within a Stack using relative (proportional) coordinates. Unlike PositionedTransition which uses absolute pixel values, RelativePositionedTransition works with relative rectangles that scale based on parent dimensions. This guide covers comprehensive strategies for localizing RelativePositionedTransition widgets in Flutter multilingual applications.

Understanding RelativePositionedTransition Localization

RelativePositionedTransition widgets require localization for:

  • Responsive layouts: Position animations that scale with screen size
  • Onboarding flows: Spotlight effects and guided tours
  • Dashboard widgets: Animated card repositioning
  • Image galleries: Proportional image transitions
  • Game interfaces: Character and element positioning
  • Tutorial overlays: Highlighting UI elements

Basic RelativePositionedTransition with Localized Content

Start with a simple relative position animation:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedRelativePositionDemo extends StatefulWidget {
  const LocalizedRelativePositionDemo({super.key});

  @override
  State<LocalizedRelativePositionDemo> createState() => _LocalizedRelativePositionDemoState();
}

class _LocalizedRelativePositionDemoState extends State<LocalizedRelativePositionDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<RelativeRect> _positionAnimation;
  bool _isAtCorner = false;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 600),
    );

    // Animate from center to top-right corner
    _positionAnimation = RelativeRectTween(
      begin: const RelativeRect.fromLTRB(0.35, 0.4, 0.35, 0.4),
      end: const RelativeRect.fromLTRB(0.7, 0.05, 0.05, 0.75),
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOutCubic,
    ));
  }

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

  void _togglePosition() {
    setState(() {
      _isAtCorner = !_isAtCorner;
      if (_isAtCorner) {
        _controller.forward();
      } else {
        _controller.reverse();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.relativePositionTitle)),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              l10n.relativePositionDescription,
              style: Theme.of(context).textTheme.bodyLarge,
              textAlign: TextAlign.center,
            ),
          ),
          Expanded(
            child: Container(
              margin: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                border: Border.all(
                  color: Theme.of(context).colorScheme.outline,
                ),
                borderRadius: BorderRadius.circular(16),
              ),
              child: Stack(
                children: [
                  // Grid lines for reference
                  ...List.generate(3, (i) {
                    final fraction = (i + 1) / 4;
                    return Positioned(
                      left: 0,
                      right: 0,
                      top: 0,
                      bottom: 0,
                      child: FractionallySizedBox(
                        heightFactor: 1,
                        widthFactor: 1,
                        child: CustomPaint(
                          painter: _GridPainter(
                            color: Theme.of(context).colorScheme.outlineVariant,
                          ),
                        ),
                      ),
                    );
                  }),
                  // Animated element
                  RelativePositionedTransition(
                    rect: _positionAnimation,
                    size: const Size(200, 200),
                    child: Semantics(
                      label: l10n.movingElementAccessibility(
                        _isAtCorner ? l10n.corner : l10n.center,
                      ),
                      child: Card(
                        elevation: 8,
                        color: Theme.of(context).colorScheme.primaryContainer,
                        child: Center(
                          child: Column(
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              Icon(
                                Icons.open_with,
                                size: 32,
                                color: Theme.of(context).colorScheme.onPrimaryContainer,
                              ),
                              const SizedBox(height: 8),
                              Text(
                                _isAtCorner ? l10n.atCorner : l10n.atCenter,
                                style: TextStyle(
                                  color: Theme.of(context).colorScheme.onPrimaryContainer,
                                  fontWeight: FontWeight.w600,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: ElevatedButton(
              onPressed: _togglePosition,
              child: Text(_isAtCorner ? l10n.moveToCenter : l10n.moveToCorner),
            ),
          ),
        ],
      ),
    );
  }
}

class _GridPainter extends CustomPainter {
  final Color color;

  _GridPainter({required this.color});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..strokeWidth = 1;

    // Vertical lines
    for (int i = 1; i < 4; i++) {
      final x = size.width * i / 4;
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
    }

    // Horizontal lines
    for (int i = 1; i < 4; i++) {
      final y = size.height * i / 4;
      canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

ARB File Structure for RelativePositionedTransition

{
  "relativePositionTitle": "Relative Position Animation",
  "@relativePositionTitle": {
    "description": "Title for relative position demo"
  },
  "relativePositionDescription": "The card moves proportionally within the container",
  "movingElementAccessibility": "Moving element, currently at {position}",
  "@movingElementAccessibility": {
    "placeholders": {
      "position": {"type": "String"}
    }
  },
  "corner": "corner",
  "center": "center",
  "atCorner": "At Corner",
  "atCenter": "At Center",
  "moveToCenter": "Move to Center",
  "moveToCorner": "Move to Corner"
}

Onboarding Spotlight Tutorial

Create an onboarding flow with spotlight effects:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedOnboardingSpotlight extends StatefulWidget {
  const LocalizedOnboardingSpotlight({super.key});

  @override
  State<LocalizedOnboardingSpotlight> createState() => _LocalizedOnboardingSpotlightState();
}

class _LocalizedOnboardingSpotlightState extends State<LocalizedOnboardingSpotlight>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  int _currentStep = 0;
  bool _showOverlay = true;

  final List<_SpotlightTarget> _targets = [
    _SpotlightTarget(
      rect: const RelativeRect.fromLTRB(0.02, 0.02, 0.7, 0.9),
      titleKey: 'menuTitle',
      descriptionKey: 'menuDescription',
    ),
    _SpotlightTarget(
      rect: const RelativeRect.fromLTRB(0.3, 0.15, 0.3, 0.7),
      titleKey: 'searchTitle',
      descriptionKey: 'searchDescription',
    ),
    _SpotlightTarget(
      rect: const RelativeRect.fromLTRB(0.7, 0.02, 0.02, 0.9),
      titleKey: 'profileTitle',
      descriptionKey: 'profileDescription',
    ),
    _SpotlightTarget(
      rect: const RelativeRect.fromLTRB(0.1, 0.8, 0.1, 0.02),
      titleKey: 'actionTitle',
      descriptionKey: 'actionDescription',
    ),
  ];

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    )..forward();
  }

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

  void _nextStep() {
    if (_currentStep < _targets.length - 1) {
      _controller.reverse().then((_) {
        setState(() {
          _currentStep++;
        });
        _controller.forward();
      });
    } else {
      setState(() {
        _showOverlay = false;
      });
    }
  }

  void _previousStep() {
    if (_currentStep > 0) {
      _controller.reverse().then((_) {
        setState(() {
          _currentStep--;
        });
        _controller.forward();
      });
    }
  }

  void _skipTutorial() {
    setState(() {
      _showOverlay = false;
    });
  }

  void _restartTutorial() {
    setState(() {
      _currentStep = 0;
      _showOverlay = true;
    });
    _controller.forward(from: 0);
  }

  String _getLocalizedText(String key, AppLocalizations l10n) {
    switch (key) {
      case 'menuTitle':
        return l10n.spotlightMenuTitle;
      case 'menuDescription':
        return l10n.spotlightMenuDescription;
      case 'searchTitle':
        return l10n.spotlightSearchTitle;
      case 'searchDescription':
        return l10n.spotlightSearchDescription;
      case 'profileTitle':
        return l10n.spotlightProfileTitle;
      case 'profileDescription':
        return l10n.spotlightProfileDescription;
      case 'actionTitle':
        return l10n.spotlightActionTitle;
      case 'actionDescription':
        return l10n.spotlightActionDescription;
      default:
        return '';
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.onboardingTitle),
        leading: IconButton(
          icon: const Icon(Icons.menu),
          onPressed: () {},
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () {},
          ),
          IconButton(
            icon: const Icon(Icons.person),
            onPressed: () {},
          ),
        ],
      ),
      body: Stack(
        children: [
          // Main content
          Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  l10n.welcomeMessage,
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                const SizedBox(height: 8),
                Text(
                  l10n.welcomeSubtitle,
                  style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                    color: Theme.of(context).colorScheme.onSurfaceVariant,
                  ),
                ),
                const SizedBox(height: 24),
                // Sample content cards
                ...List.generate(3, (index) {
                  return Card(
                    margin: const EdgeInsets.only(bottom: 12),
                    child: ListTile(
                      leading: CircleAvatar(
                        child: Text('${index + 1}'),
                      ),
                      title: Text(l10n.sampleCardTitle(index + 1)),
                      subtitle: Text(l10n.sampleCardSubtitle),
                      trailing: const Icon(Icons.chevron_right),
                    ),
                  );
                }),
                const Spacer(),
                if (!_showOverlay)
                  Center(
                    child: TextButton.icon(
                      onPressed: _restartTutorial,
                      icon: const Icon(Icons.replay),
                      label: Text(l10n.restartTutorial),
                    ),
                  ),
              ],
            ),
          ),
          // FAB placeholder
          Positioned(
            right: 16,
            bottom: 16,
            child: FloatingActionButton(
              onPressed: () {},
              child: const Icon(Icons.add),
            ),
          ),
          // Tutorial overlay
          if (_showOverlay) ...[
            // Dark overlay
            Positioned.fill(
              child: GestureDetector(
                onTap: _nextStep,
                child: AnimatedBuilder(
                  animation: _controller,
                  builder: (context, child) {
                    return CustomPaint(
                      painter: _SpotlightPainter(
                        target: _targets[_currentStep],
                        animation: _controller,
                        overlayColor: Colors.black.withOpacity(0.7),
                      ),
                      child: const SizedBox.expand(),
                    );
                  },
                ),
              ),
            ),
            // Info card with relative positioning
            RelativePositionedTransition(
              rect: RelativeRectTween(
                begin: _getInfoCardPosition(_currentStep),
                end: _getInfoCardPosition(_currentStep),
              ).animate(_controller),
              size: MediaQuery.of(context).size,
              child: FadeTransition(
                opacity: _controller,
                child: Card(
                  margin: const EdgeInsets.all(16),
                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Row(
                          children: [
                            Container(
                              padding: const EdgeInsets.symmetric(
                                horizontal: 8,
                                vertical: 4,
                              ),
                              decoration: BoxDecoration(
                                color: Theme.of(context).colorScheme.primaryContainer,
                                borderRadius: BorderRadius.circular(12),
                              ),
                              child: Text(
                                l10n.stepIndicator(_currentStep + 1, _targets.length),
                                style: TextStyle(
                                  color: Theme.of(context).colorScheme.onPrimaryContainer,
                                  fontSize: 12,
                                  fontWeight: FontWeight.w600,
                                ),
                              ),
                            ),
                            const Spacer(),
                            TextButton(
                              onPressed: _skipTutorial,
                              child: Text(l10n.skipTutorial),
                            ),
                          ],
                        ),
                        const SizedBox(height: 12),
                        Text(
                          _getLocalizedText(_targets[_currentStep].titleKey, l10n),
                          style: Theme.of(context).textTheme.titleLarge,
                        ),
                        const SizedBox(height: 8),
                        Text(
                          _getLocalizedText(_targets[_currentStep].descriptionKey, l10n),
                          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                            color: Theme.of(context).colorScheme.onSurfaceVariant,
                          ),
                        ),
                        const SizedBox(height: 16),
                        Row(
                          children: [
                            if (_currentStep > 0)
                              OutlinedButton(
                                onPressed: _previousStep,
                                child: Text(l10n.previousButton),
                              ),
                            const Spacer(),
                            ElevatedButton(
                              onPressed: _nextStep,
                              child: Text(
                                _currentStep < _targets.length - 1
                                    ? l10n.nextButton
                                    : l10n.finishButton,
                              ),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ],
        ],
      ),
    );
  }

  RelativeRect _getInfoCardPosition(int step) {
    switch (step) {
      case 0: // Menu - show card on right
        return const RelativeRect.fromLTRB(0.35, 0.3, 0.05, 0.3);
      case 1: // Search - show card below
        return const RelativeRect.fromLTRB(0.1, 0.35, 0.1, 0.25);
      case 2: // Profile - show card on left
        return const RelativeRect.fromLTRB(0.05, 0.3, 0.35, 0.3);
      case 3: // FAB - show card above
        return const RelativeRect.fromLTRB(0.1, 0.35, 0.1, 0.35);
      default:
        return const RelativeRect.fromLTRB(0.1, 0.4, 0.1, 0.4);
    }
  }
}

class _SpotlightTarget {
  final RelativeRect rect;
  final String titleKey;
  final String descriptionKey;

  _SpotlightTarget({
    required this.rect,
    required this.titleKey,
    required this.descriptionKey,
  });
}

class _SpotlightPainter extends CustomPainter {
  final _SpotlightTarget target;
  final Animation<double> animation;
  final Color overlayColor;

  _SpotlightPainter({
    required this.target,
    required this.animation,
    required this.overlayColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Rect.fromLTRB(
      target.rect.left * size.width,
      target.rect.top * size.height,
      size.width - target.rect.right * size.width,
      size.height - target.rect.bottom * size.height,
    );

    final expandedRect = Rect.fromCenter(
      center: rect.center,
      width: rect.width + 16,
      height: rect.height + 16,
    );

    final path = Path()
      ..addRect(Rect.fromLTWH(0, 0, size.width, size.height))
      ..addRRect(RRect.fromRectAndRadius(expandedRect, const Radius.circular(8)))
      ..fillType = PathFillType.evenOdd;

    final paint = Paint()
      ..color = overlayColor.withOpacity(overlayColor.opacity * animation.value);

    canvas.drawPath(path, paint);

    // Draw spotlight border
    final borderPaint = Paint()
      ..color = Colors.white.withOpacity(animation.value)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    canvas.drawRRect(
      RRect.fromRectAndRadius(expandedRect, const Radius.circular(8)),
      borderPaint,
    );
  }

  @override
  bool shouldRepaint(covariant _SpotlightPainter oldDelegate) {
    return oldDelegate.target != target || oldDelegate.animation != animation;
  }
}

Animated Dashboard Cards

Create a dashboard with animated card repositioning:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedAnimatedDashboard extends StatefulWidget {
  const LocalizedAnimatedDashboard({super.key});

  @override
  State<LocalizedAnimatedDashboard> createState() => _LocalizedAnimatedDashboardState();
}

class _LocalizedAnimatedDashboardState extends State<LocalizedAnimatedDashboard>
    with TickerProviderStateMixin {
  late List<AnimationController> _controllers;
  bool _isCompactMode = false;

  final List<_DashboardCard> _cards = [
    _DashboardCard(
      id: 'revenue',
      icon: Icons.attach_money,
      color: Colors.green,
      expandedRect: const RelativeRect.fromLTRB(0.02, 0.02, 0.52, 0.52),
      compactRect: const RelativeRect.fromLTRB(0.02, 0.02, 0.52, 0.76),
    ),
    _DashboardCard(
      id: 'users',
      icon: Icons.people,
      color: Colors.blue,
      expandedRect: const RelativeRect.fromLTRB(0.52, 0.02, 0.02, 0.52),
      compactRect: const RelativeRect.fromLTRB(0.52, 0.02, 0.02, 0.76),
    ),
    _DashboardCard(
      id: 'orders',
      icon: Icons.shopping_cart,
      color: Colors.orange,
      expandedRect: const RelativeRect.fromLTRB(0.02, 0.52, 0.52, 0.02),
      compactRect: const RelativeRect.fromLTRB(0.02, 0.27, 0.52, 0.51),
    ),
    _DashboardCard(
      id: 'growth',
      icon: Icons.trending_up,
      color: Colors.purple,
      expandedRect: const RelativeRect.fromLTRB(0.52, 0.52, 0.02, 0.02),
      compactRect: const RelativeRect.fromLTRB(0.52, 0.27, 0.02, 0.51),
    ),
  ];

  @override
  void initState() {
    super.initState();
    _controllers = List.generate(
      _cards.length,
      (index) => AnimationController(
        vsync: this,
        duration: Duration(milliseconds: 400 + index * 100),
      ),
    );
  }

  @override
  void dispose() {
    for (final controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  void _toggleLayout() {
    setState(() {
      _isCompactMode = !_isCompactMode;
    });

    for (int i = 0; i < _controllers.length; i++) {
      Future.delayed(Duration(milliseconds: i * 50), () {
        if (_isCompactMode) {
          _controllers[i].forward();
        } else {
          _controllers[i].reverse();
        }
      });
    }
  }

  String _getCardTitle(String id, AppLocalizations l10n) {
    switch (id) {
      case 'revenue':
        return l10n.revenueTitle;
      case 'users':
        return l10n.usersTitle;
      case 'orders':
        return l10n.ordersTitle;
      case 'growth':
        return l10n.growthTitle;
      default:
        return '';
    }
  }

  String _getCardValue(String id, AppLocalizations l10n) {
    switch (id) {
      case 'revenue':
        return l10n.revenueValue;
      case 'users':
        return l10n.usersValue;
      case 'orders':
        return l10n.ordersValue;
      case 'growth':
        return l10n.growthValue;
      default:
        return '';
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.dashboardTitle),
        actions: [
          IconButton(
            icon: Icon(_isCompactMode ? Icons.grid_view : Icons.view_compact),
            onPressed: _toggleLayout,
            tooltip: _isCompactMode ? l10n.expandedView : l10n.compactView,
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(8),
        child: LayoutBuilder(
          builder: (context, constraints) {
            return Stack(
              children: _cards.asMap().entries.map((entry) {
                final index = entry.key;
                final card = entry.value;

                final animation = RelativeRectTween(
                  begin: card.expandedRect,
                  end: card.compactRect,
                ).animate(CurvedAnimation(
                  parent: _controllers[index],
                  curve: Curves.easeInOutCubic,
                ));

                return RelativePositionedTransition(
                  rect: animation,
                  size: Size(constraints.maxWidth, constraints.maxHeight),
                  child: Semantics(
                    label: l10n.dashboardCardAccessibility(
                      _getCardTitle(card.id, l10n),
                      _getCardValue(card.id, l10n),
                    ),
                    child: Card(
                      elevation: 4,
                      child: Container(
                        padding: const EdgeInsets.all(16),
                        decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(12),
                          gradient: LinearGradient(
                            begin: Alignment.topLeft,
                            end: Alignment.bottomRight,
                            colors: [
                              card.color.withOpacity(0.8),
                              card.color,
                            ],
                          ),
                        ),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Row(
                              children: [
                                Icon(
                                  card.icon,
                                  color: Colors.white,
                                  size: 24,
                                ),
                                const Spacer(),
                                Container(
                                  padding: const EdgeInsets.symmetric(
                                    horizontal: 8,
                                    vertical: 4,
                                  ),
                                  decoration: BoxDecoration(
                                    color: Colors.white.withOpacity(0.2),
                                    borderRadius: BorderRadius.circular(12),
                                  ),
                                  child: Text(
                                    l10n.liveIndicator,
                                    style: const TextStyle(
                                      color: Colors.white,
                                      fontSize: 10,
                                      fontWeight: FontWeight.w600,
                                    ),
                                  ),
                                ),
                              ],
                            ),
                            const Spacer(),
                            Text(
                              _getCardTitle(card.id, l10n),
                              style: TextStyle(
                                color: Colors.white.withOpacity(0.9),
                                fontSize: 14,
                              ),
                            ),
                            const SizedBox(height: 4),
                            Text(
                              _getCardValue(card.id, l10n),
                              style: const TextStyle(
                                color: Colors.white,
                                fontSize: 28,
                                fontWeight: FontWeight.bold,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                  ),
                );
              }).toList(),
            );
          },
        ),
      ),
    );
  }
}

class _DashboardCard {
  final String id;
  final IconData icon;
  final Color color;
  final RelativeRect expandedRect;
  final RelativeRect compactRect;

  _DashboardCard({
    required this.id,
    required this.icon,
    required this.color,
    required this.expandedRect,
    required this.compactRect,
  });
}

Image Gallery with Relative Transitions

Create a photo gallery with proportional transitions:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedRelativeGallery extends StatefulWidget {
  const LocalizedRelativeGallery({super.key});

  @override
  State<LocalizedRelativeGallery> createState() => _LocalizedRelativeGalleryState();
}

class _LocalizedRelativeGalleryState extends State<LocalizedRelativeGallery>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  int? _selectedIndex;
  int? _previousIndex;

  final List<_GalleryItem> _items = [
    _GalleryItem(
      imageUrl: 'https://picsum.photos/400/300?random=1',
      titleKey: 'nature',
      gridRect: const RelativeRect.fromLTRB(0.01, 0.01, 0.67, 0.51),
    ),
    _GalleryItem(
      imageUrl: 'https://picsum.photos/400/300?random=2',
      titleKey: 'architecture',
      gridRect: const RelativeRect.fromLTRB(0.34, 0.01, 0.34, 0.51),
    ),
    _GalleryItem(
      imageUrl: 'https://picsum.photos/400/300?random=3',
      titleKey: 'travel',
      gridRect: const RelativeRect.fromLTRB(0.67, 0.01, 0.01, 0.51),
    ),
    _GalleryItem(
      imageUrl: 'https://picsum.photos/400/300?random=4',
      titleKey: 'food',
      gridRect: const RelativeRect.fromLTRB(0.01, 0.51, 0.51, 0.01),
    ),
    _GalleryItem(
      imageUrl: 'https://picsum.photos/400/300?random=5',
      titleKey: 'animals',
      gridRect: const RelativeRect.fromLTRB(0.51, 0.51, 0.01, 0.01),
    ),
  ];

  final RelativeRect _expandedRect = const RelativeRect.fromLTRB(0.05, 0.1, 0.05, 0.1);

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 400),
    );
  }

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

  void _selectItem(int index) {
    setState(() {
      _previousIndex = _selectedIndex;
      _selectedIndex = index;
    });
    _controller.forward(from: 0);
  }

  void _closeExpanded() {
    _controller.reverse().then((_) {
      setState(() {
        _previousIndex = _selectedIndex;
        _selectedIndex = null;
      });
    });
  }

  String _getTitle(String key, AppLocalizations l10n) {
    switch (key) {
      case 'nature':
        return l10n.galleryNature;
      case 'architecture':
        return l10n.galleryArchitecture;
      case 'travel':
        return l10n.galleryTravel;
      case 'food':
        return l10n.galleryFood;
      case 'animals':
        return l10n.galleryAnimals;
      default:
        return '';
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.galleryTitle)),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final size = Size(constraints.maxWidth, constraints.maxHeight);

          return Stack(
            children: [
              // Grid items
              ..._items.asMap().entries.map((entry) {
                final index = entry.key;
                final item = entry.value;
                final isSelected = index == _selectedIndex;

                if (isSelected) return const SizedBox.shrink();

                return Positioned.fromRelativeRect(
                  rect: item.gridRect.resolve(Directionality.of(context)),
                  child: GestureDetector(
                    onTap: () => _selectItem(index),
                    child: Semantics(
                      button: true,
                      label: l10n.galleryItemAccessibility(_getTitle(item.titleKey, l10n)),
                      child: Card(
                        clipBehavior: Clip.antiAlias,
                        child: Stack(
                          fit: StackFit.expand,
                          children: [
                            Container(
                              color: Theme.of(context).colorScheme.surfaceContainerHighest,
                              child: Icon(
                                Icons.image,
                                size: 48,
                                color: Theme.of(context).colorScheme.onSurfaceVariant,
                              ),
                            ),
                            Positioned(
                              left: 0,
                              right: 0,
                              bottom: 0,
                              child: Container(
                                padding: const EdgeInsets.all(8),
                                decoration: BoxDecoration(
                                  gradient: LinearGradient(
                                    begin: Alignment.topCenter,
                                    end: Alignment.bottomCenter,
                                    colors: [
                                      Colors.transparent,
                                      Colors.black.withOpacity(0.7),
                                    ],
                                  ),
                                ),
                                child: Text(
                                  _getTitle(item.titleKey, l10n),
                                  style: const TextStyle(
                                    color: Colors.white,
                                    fontWeight: FontWeight.w600,
                                  ),
                                ),
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                  ),
                );
              }),
              // Expanded item with animation
              if (_selectedIndex != null)
                GestureDetector(
                  onTap: _closeExpanded,
                  child: AnimatedBuilder(
                    animation: _controller,
                    builder: (context, child) {
                      return Container(
                        color: Colors.black.withOpacity(0.5 * _controller.value),
                      );
                    },
                  ),
                ),
              if (_selectedIndex != null)
                RelativePositionedTransition(
                  rect: RelativeRectTween(
                    begin: _items[_selectedIndex!].gridRect,
                    end: _expandedRect,
                  ).animate(CurvedAnimation(
                    parent: _controller,
                    curve: Curves.easeInOutCubic,
                  )),
                  size: size,
                  child: GestureDetector(
                    onTap: _closeExpanded,
                    child: Card(
                      clipBehavior: Clip.antiAlias,
                      child: Stack(
                        fit: StackFit.expand,
                        children: [
                          Container(
                            color: Theme.of(context).colorScheme.surfaceContainerHighest,
                            child: Icon(
                              Icons.image,
                              size: 96,
                              color: Theme.of(context).colorScheme.onSurfaceVariant,
                            ),
                          ),
                          Positioned(
                            left: 0,
                            right: 0,
                            bottom: 0,
                            child: Container(
                              padding: const EdgeInsets.all(16),
                              decoration: BoxDecoration(
                                gradient: LinearGradient(
                                  begin: Alignment.topCenter,
                                  end: Alignment.bottomCenter,
                                  colors: [
                                    Colors.transparent,
                                    Colors.black.withOpacity(0.8),
                                  ],
                                ),
                              ),
                              child: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: [
                                  Text(
                                    _getTitle(_items[_selectedIndex!].titleKey, l10n),
                                    style: const TextStyle(
                                      color: Colors.white,
                                      fontSize: 24,
                                      fontWeight: FontWeight.bold,
                                    ),
                                  ),
                                  const SizedBox(height: 8),
                                  Text(
                                    l10n.tapToClose,
                                    style: TextStyle(
                                      color: Colors.white.withOpacity(0.7),
                                      fontSize: 14,
                                    ),
                                  ),
                                ],
                              ),
                            ),
                          ),
                          Positioned(
                            top: 8,
                            right: 8,
                            child: IconButton(
                              onPressed: _closeExpanded,
                              icon: const Icon(Icons.close),
                              color: Colors.white,
                              style: IconButton.styleFrom(
                                backgroundColor: Colors.black.withOpacity(0.3),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
            ],
          );
        },
      ),
    );
  }
}

class _GalleryItem {
  final String imageUrl;
  final String titleKey;
  final RelativeRect gridRect;

  _GalleryItem({
    required this.imageUrl,
    required this.titleKey,
    required this.gridRect,
  });
}

Complete ARB File for RelativePositionedTransition

{
  "@@locale": "en",

  "relativePositionTitle": "Relative Position Animation",
  "relativePositionDescription": "The card moves proportionally within the container",
  "movingElementAccessibility": "Moving element, currently at {position}",
  "@movingElementAccessibility": {
    "placeholders": {
      "position": {"type": "String"}
    }
  },
  "corner": "corner",
  "center": "center",
  "atCorner": "At Corner",
  "atCenter": "At Center",
  "moveToCenter": "Move to Center",
  "moveToCorner": "Move to Corner",

  "onboardingTitle": "App Tour",
  "welcomeMessage": "Welcome to the App",
  "welcomeSubtitle": "Let us show you around",
  "sampleCardTitle": "Feature {number}",
  "@sampleCardTitle": {
    "placeholders": {
      "number": {"type": "int"}
    }
  },
  "sampleCardSubtitle": "Tap to learn more",
  "restartTutorial": "Restart Tutorial",
  "stepIndicator": "Step {current} of {total}",
  "@stepIndicator": {
    "placeholders": {
      "current": {"type": "int"},
      "total": {"type": "int"}
    }
  },
  "skipTutorial": "Skip",
  "previousButton": "Previous",
  "nextButton": "Next",
  "finishButton": "Got it!",
  "spotlightMenuTitle": "Navigation Menu",
  "spotlightMenuDescription": "Access all app sections from here. Tap the menu icon to explore different features.",
  "spotlightSearchTitle": "Search",
  "spotlightSearchDescription": "Quickly find what you're looking for. Search across all content and features.",
  "spotlightProfileTitle": "Your Profile",
  "spotlightProfileDescription": "Manage your account settings, preferences, and personal information.",
  "spotlightActionTitle": "Quick Actions",
  "spotlightActionDescription": "Tap this button to create new items or perform common actions quickly.",

  "dashboardTitle": "Dashboard",
  "expandedView": "Expanded View",
  "compactView": "Compact View",
  "revenueTitle": "Revenue",
  "revenueValue": "$12,450",
  "usersTitle": "Active Users",
  "usersValue": "2,847",
  "ordersTitle": "Orders",
  "ordersValue": "384",
  "growthTitle": "Growth",
  "growthValue": "+23%",
  "liveIndicator": "LIVE",
  "dashboardCardAccessibility": "{title}: {value}",
  "@dashboardCardAccessibility": {
    "placeholders": {
      "title": {"type": "String"},
      "value": {"type": "String"}
    }
  },

  "galleryTitle": "Photo Gallery",
  "galleryNature": "Nature",
  "galleryArchitecture": "Architecture",
  "galleryTravel": "Travel",
  "galleryFood": "Food",
  "galleryAnimals": "Animals",
  "galleryItemAccessibility": "View {title} photos",
  "@galleryItemAccessibility": {
    "placeholders": {
      "title": {"type": "String"}
    }
  },
  "tapToClose": "Tap anywhere to close"
}

Best Practices Summary

  1. Use RelativeRect for proportional layouts: Positions scale with container size
  2. Combine with LayoutBuilder: Get accurate container dimensions
  3. Handle RTL with resolve(): Call resolve(Directionality.of(context)) for RTL support
  4. Dispose controllers properly: Always dispose AnimationControllers
  5. Add accessibility labels: Use Semantics with position state information
  6. Use appropriate curves: easeInOutCubic provides smooth movement
  7. Test on various screen sizes: Verify proportional positioning works correctly
  8. Consider overlap: Manage z-index when items move over each other
  9. Stagger animations: Add delays between multiple items for visual interest
  10. Keep computations light: Calculate positions efficiently to maintain smooth animations

Conclusion

RelativePositionedTransition provides precise control over proportional position animations within Stack widgets. By using RelativeRect instead of absolute pixel values, you can create responsive animations that scale correctly across different screen sizes. This makes it ideal for onboarding tutorials, dashboard layouts, and image galleries that need to work seamlessly on phones, tablets, and desktop displays.

The key advantages are automatic scaling with parent dimensions and the ability to express positions as fractions of the available space. Remember to test your animations on various screen sizes and always provide accessibility labels for moving elements.