Fix timeline issues with event markers alignment

This commit is contained in:
Shawn 2022-11-05 02:10:11 -06:00
parent c967be4d82
commit 72e727898a
8 changed files with 124 additions and 71 deletions

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:wonders/common_libs.dart'; import 'package:wonders/common_libs.dart';
import 'package:wonders/logic/common/debouncer.dart'; import 'package:wonders/logic/common/debouncer.dart';
import 'package:wonders/logic/common/string_utils.dart'; import 'package:wonders/logic/common/string_utils.dart';
@ -22,6 +23,7 @@ part 'widgets/_scrolling_viewport.dart';
part 'widgets/_scrolling_viewport_controller.dart'; part 'widgets/_scrolling_viewport_controller.dart';
part 'widgets/_timeline_section.dart'; part 'widgets/_timeline_section.dart';
part 'widgets/_year_markers.dart'; part 'widgets/_year_markers.dart';
part 'widgets/_animated_era_text.dart';
class TimelineScreen extends StatefulWidget { class TimelineScreen extends StatefulWidget {
final WonderType? type; final WonderType? type;
@ -35,12 +37,15 @@ class TimelineScreen extends StatefulWidget {
class _TimelineScreenState extends State<TimelineScreen> { class _TimelineScreenState extends State<TimelineScreen> {
/// Create a scroll controller that the top and bottom timelines can share /// Create a scroll controller that the top and bottom timelines can share
final ScrollController _scroller = ScrollController(); final ScrollController _scroller = ScrollController();
final _year = ValueNotifier<int>(0);
void _handleViewportYearChanged(int value) => _year.value = value;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, constraints) { return LayoutBuilder(builder: (_, constraints) {
// Determine min and max size of the timeline based on the size available to this widget // Determine min and max size of the timeline based on the size available to this widget
const double scrubberSize = 70; const double scrubberSize = 80;
const double minSize = 1200; const double minSize = 1200;
const double maxSize = 5500; const double maxSize = 5500;
return Container( return Container(
@ -53,27 +58,33 @@ class _TimelineScreenState extends State<TimelineScreen> {
/// Vertically scrolling timeline, manages a ScrollController. /// Vertically scrolling timeline, manages a ScrollController.
Expanded( Expanded(
child: Stack( child: _ScrollingViewport(
children: [
/// The timeline content itself
_ScrollingViewport(
scroller: _scroller, scroller: _scroller,
minSize: minSize, minSize: minSize,
maxSize: maxSize, maxSize: maxSize,
selectedWonder: widget.type, selectedWonder: widget.type,
), onYearChanged: _handleViewportYearChanged,
],
), ),
), ),
/// Era Text (classical, modern etc)
ValueListenableBuilder<int>(
valueListenable: _year,
builder: (_, value, __) => _AnimatedEraText(value),
),
Gap($styles.insets.xs),
/// Mini Horizontal timeline, reacts to the state of the larger scrolling timeline, /// Mini Horizontal timeline, reacts to the state of the larger scrolling timeline,
/// and changes the timelines scroll position on Hz drag /// and changes the timelines scroll position on Hz drag
_BottomScrubber( Padding(
padding: EdgeInsets.symmetric(horizontal: $styles.insets.lg),
child: _BottomScrubber(
_scroller, _scroller,
size: scrubberSize, size: scrubberSize,
timelineMinSize: minSize, timelineMinSize: minSize,
selectedWonder: widget.type, selectedWonder: widget.type,
), ),
),
Gap($styles.insets.lg), Gap($styles.insets.lg),
], ],
), ),

View File

@ -0,0 +1,16 @@
part of '../timeline_screen.dart';
class _AnimatedEraText extends StatelessWidget {
const _AnimatedEraText(this.year);
final int year;
@override
Widget build(BuildContext context) {
String era = StringUtils.getEra(year);
final style = $styles.text.body.copyWith(color: $styles.colors.offWhite);
return Semantics(
liveRegion: true,
child: Text(era, style: style),
).animate(key: ValueKey(era)).fadeIn().slide(begin: Offset(0, .2));
}
}

View File

@ -41,8 +41,12 @@ class _BottomScrubber extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
/// Timeline background /// Timeline background
Padding( Container(
padding: EdgeInsets.all($styles.insets.sm), padding: EdgeInsets.all($styles.insets.md),
decoration: BoxDecoration(
color: $styles.colors.greyStrong,
borderRadius: BorderRadius.circular($styles.corners.md),
),
child: WondersTimelineBuilder( child: WondersTimelineBuilder(
crossAxisGap: 4, crossAxisGap: 4,
selectedWonders: selectedWonder != null ? [selectedWonder!] : [], selectedWonders: selectedWonder != null ? [selectedWonder!] : [],

View File

@ -30,6 +30,7 @@ class _DashedDividerWithYear extends StatelessWidget {
shadows: $styles.shadows.textStrong, shadows: $styles.shadows.textStrong,
), ),
), ),
Gap($styles.insets.xs),
], ],
), ),
), ),

View File

@ -13,6 +13,7 @@ class _EventMarkers extends StatefulWidget {
} }
class _EventMarkersState extends State<_EventMarkers> { class _EventMarkersState extends State<_EventMarkers> {
bool get showReferenceMarkers => kDebugMode;
int get startYr => wondersLogic.timelineStartYear; int get startYr => wondersLogic.timelineStartYear;
int get endYr => wondersLogic.timelineEndYear; int get endYr => wondersLogic.timelineEndYear;
@ -60,7 +61,7 @@ class _EventMarkersState extends State<_EventMarkers> {
/// Create a marker for each event /// Create a marker for each event
List<Widget> markers = timelineLogic.events.map((event) { List<Widget> markers = timelineLogic.events.map((event) {
final offsetY = _calculateOffsetY(event.year); double offsetY = _calculateOffsetY(event.year);
return _EventMarker(offsetY, return _EventMarker(offsetY,
isSelected: event == selectedEvent, semanticLabel: '${event.year}: ${event.description}'); isSelected: event == selectedEvent, semanticLabel: '${event.year}: ${event.description}');
}).toList(); }).toList();
@ -73,13 +74,33 @@ class _EventMarkersState extends State<_EventMarkers> {
padding: EdgeInsets.only(left: 75), padding: EdgeInsets.only(left: 75),
child: SizedBox( child: SizedBox(
width: 20, width: 20,
child: Stack(children: markers), child: Stack(
children: [
...markers,
if (showReferenceMarkers) ..._buildReferenceMarkers(),
],
),
), ),
), ),
); );
}), }),
); );
} }
List<Widget> _buildReferenceMarkers() {
final marker = Container(color: Colors.red.withOpacity(.4), width: 10, height: 10);
return [
Align(
alignment: Alignment.topCenter,
child: FractionalTranslation(translation: Offset(0, -.5), child: marker),
),
Align(alignment: Alignment.center, child: marker),
Align(
alignment: Alignment.bottomCenter,
child: FractionalTranslation(translation: Offset(0, .5), child: marker),
),
];
}
} }
/// A dot that represents a single global event. /// A dot that represents a single global event.
@ -99,6 +120,12 @@ class _EventMarker extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Align( return Align(
alignment: Alignment(0, -1 + offset * 2), alignment: Alignment(0, -1 + offset * 2),
// Use an OverflowBox wrapped in a zero-height sized box so that al
// This allows alignment-based positioning to be accurate even at the edges of the parent.
child: SizedBox(
height: 0,
child: OverflowBox(
maxHeight: 30,
child: Semantics( child: Semantics(
label: semanticLabel, label: semanticLabel,
child: Container( child: Container(
@ -120,6 +147,8 @@ class _EventMarker extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
} }

View File

@ -9,11 +9,13 @@ class _ScrollingViewport extends StatefulWidget {
required this.minSize, required this.minSize,
required this.maxSize, required this.maxSize,
required this.selectedWonder, required this.selectedWonder,
this.onYearChanged,
}) : super(key: key); }) : super(key: key);
final double minSize; final double minSize;
final double maxSize; final double maxSize;
final ScrollController scroller; final ScrollController scroller;
final WonderType? selectedWonder; final WonderType? selectedWonder;
final void Function(int year)? onYearChanged;
final void Function(_ScrollingViewportController controller)? onInit; final void Function(_ScrollingViewportController controller)? onInit;
@override @override
@ -53,21 +55,10 @@ class _ScalingViewportState extends State<_ScrollingViewport> {
// Fade in entire view when first shown // Fade in entire view when first shown
child: Stack( child: Stack(
children: [ children: [
Column( // Main content area
children: [ _buildScrollingArea(context).animate().fadeIn(),
/// Main scrolling area, holds the year markers, and the [WondersTimelineBuilder]
Expanded(
child: _buildScrollingArea(context),
),
Gap($styles.insets.xs),
/// Era Text (classical, modern etc) // Dashed line with a year that changes as we scroll
_buildAnimatedEraText(context),
Gap($styles.insets.xs),
],
).animate().fadeIn(),
/// Dashed line with a year that changes as we scroll
IgnorePointer( IgnorePointer(
ignoringSemantics: false, ignoringSemantics: false,
child: AnimatedBuilder( child: AnimatedBuilder(
@ -82,23 +73,6 @@ class _ScalingViewportState extends State<_ScrollingViewport> {
); );
} }
AnimatedBuilder _buildAnimatedEraText(BuildContext context) {
return AnimatedBuilder(
animation: controller.scroller,
builder: (_, __) {
String era = StringUtils.getEra(controller.calculateYearFromScrollPos());
final style = $styles.text.body.copyWith(color: $styles.colors.offWhite);
return AnimatedSwitcher(
duration: $styles.times.fast,
child: Semantics(
liveRegion: true,
child: Text(era, key: ValueKey(era), style: style)
.animate(key: ValueKey(era))
.slide(begin: Offset(0, .2))),
);
});
}
Widget _buildScrollingArea(BuildContext context) { Widget _buildScrollingArea(BuildContext context) {
// Builds a TimelineSection, and passes it the currently selected yr based on scroll position. // Builds a TimelineSection, and passes it the currently selected yr based on scroll position.
// Rebuilds when timeline is scrolled. // Rebuilds when timeline is scrolled.

View File

@ -12,6 +12,10 @@ class _ScrollingViewportController extends ChangeNotifier {
late BoxConstraints _constraints; late BoxConstraints _constraints;
_ScrollingViewport get widget => state.widget; _ScrollingViewport get widget => state.widget;
ScrollController get scroller => widget.scroller; ScrollController get scroller => widget.scroller;
late final ValueNotifier<int> _currentYr = ValueNotifier(startYr)
..addListener(
() => state.widget.onYearChanged?.call(_currentYr.value),
);
void init() { void init() {
scheduleMicrotask(() { scheduleMicrotask(() {
@ -22,10 +26,13 @@ class _ScrollingViewportController extends ChangeNotifier {
final pos = calculateScrollPosFromYear(data.startYr); final pos = calculateScrollPosFromYear(data.startYr);
scroller.jumpTo(pos - 200); scroller.jumpTo(pos - 200);
scroller.animateTo(pos, duration: 1.35.seconds, curve: Curves.easeOutCubic); scroller.animateTo(pos, duration: 1.35.seconds, curve: Curves.easeOutCubic);
scroller.addListener(_updateCurrentYear);
} }
}); });
} }
void _updateCurrentYear() => _currentYr.value = calculateYearFromScrollPos();
/// Allows ancestors to set zoom directly /// Allows ancestors to set zoom directly
void setZoom(double d) { void setZoom(double d) {
// ignore: invalid_use_of_protected_member // ignore: invalid_use_of_protected_member

View File

@ -57,11 +57,22 @@ class _YearMarker extends StatelessWidget {
return ExcludeSemantics( return ExcludeSemantics(
child: Align( child: Align(
alignment: Alignment(0, -1 + offset * 2), alignment: Alignment(0, -1 + offset * 2),
// Use an OverflowBox wrapped in a zero-height sized box so that al
// This allows alignment-based positioning to be accurate even at the edges of the parent.
child: SizedBox(
height: 0,
child: OverflowBox(
alignment: Alignment.topCenter,
maxHeight: 100,
maxWidth: 100,
child: FractionalTranslation( child: FractionalTranslation(
translation: Offset(0, 0), translation: Offset(0, -.5),
child: Text('${yr.abs()}', style: $styles.text.body.copyWith(color: Colors.white, height: 1)), child: Text('${yr.abs()}', style: $styles.text.body.copyWith(color: Colors.white, height: 1))),
), ),
), ),
//child:,
),
); );
} }
} }