Fix timeline issues with event markers alignment
This commit is contained in:
parent
c967be4d82
commit
72e727898a
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:wonders/common_libs.dart';
|
||||
import 'package:wonders/logic/common/debouncer.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/_timeline_section.dart';
|
||||
part 'widgets/_year_markers.dart';
|
||||
part 'widgets/_animated_era_text.dart';
|
||||
|
||||
class TimelineScreen extends StatefulWidget {
|
||||
final WonderType? type;
|
||||
@ -35,12 +37,15 @@ class TimelineScreen extends StatefulWidget {
|
||||
class _TimelineScreenState extends State<TimelineScreen> {
|
||||
/// Create a scroll controller that the top and bottom timelines can share
|
||||
final ScrollController _scroller = ScrollController();
|
||||
final _year = ValueNotifier<int>(0);
|
||||
|
||||
void _handleViewportYearChanged(int value) => _year.value = value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (_, constraints) {
|
||||
// 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 maxSize = 5500;
|
||||
return Container(
|
||||
@ -53,26 +58,32 @@ class _TimelineScreenState extends State<TimelineScreen> {
|
||||
|
||||
/// Vertically scrolling timeline, manages a ScrollController.
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
/// The timeline content itself
|
||||
_ScrollingViewport(
|
||||
scroller: _scroller,
|
||||
minSize: minSize,
|
||||
maxSize: maxSize,
|
||||
selectedWonder: widget.type,
|
||||
),
|
||||
],
|
||||
child: _ScrollingViewport(
|
||||
scroller: _scroller,
|
||||
minSize: minSize,
|
||||
maxSize: maxSize,
|
||||
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,
|
||||
/// and changes the timelines scroll position on Hz drag
|
||||
_BottomScrubber(
|
||||
_scroller,
|
||||
size: scrubberSize,
|
||||
timelineMinSize: minSize,
|
||||
selectedWonder: widget.type,
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: $styles.insets.lg),
|
||||
child: _BottomScrubber(
|
||||
_scroller,
|
||||
size: scrubberSize,
|
||||
timelineMinSize: minSize,
|
||||
selectedWonder: widget.type,
|
||||
),
|
||||
),
|
||||
Gap($styles.insets.lg),
|
||||
],
|
||||
|
16
lib/ui/screens/timeline/widgets/_animated_era_text.dart
Normal file
16
lib/ui/screens/timeline/widgets/_animated_era_text.dart
Normal 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));
|
||||
}
|
||||
}
|
@ -41,8 +41,12 @@ class _BottomScrubber extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
/// Timeline background
|
||||
Padding(
|
||||
padding: EdgeInsets.all($styles.insets.sm),
|
||||
Container(
|
||||
padding: EdgeInsets.all($styles.insets.md),
|
||||
decoration: BoxDecoration(
|
||||
color: $styles.colors.greyStrong,
|
||||
borderRadius: BorderRadius.circular($styles.corners.md),
|
||||
),
|
||||
child: WondersTimelineBuilder(
|
||||
crossAxisGap: 4,
|
||||
selectedWonders: selectedWonder != null ? [selectedWonder!] : [],
|
||||
|
@ -30,6 +30,7 @@ class _DashedDividerWithYear extends StatelessWidget {
|
||||
shadows: $styles.shadows.textStrong,
|
||||
),
|
||||
),
|
||||
Gap($styles.insets.xs),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -13,6 +13,7 @@ class _EventMarkers extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EventMarkersState extends State<_EventMarkers> {
|
||||
bool get showReferenceMarkers => kDebugMode;
|
||||
int get startYr => wondersLogic.timelineStartYear;
|
||||
|
||||
int get endYr => wondersLogic.timelineEndYear;
|
||||
@ -60,7 +61,7 @@ class _EventMarkersState extends State<_EventMarkers> {
|
||||
|
||||
/// Create a marker for each event
|
||||
List<Widget> markers = timelineLogic.events.map((event) {
|
||||
final offsetY = _calculateOffsetY(event.year);
|
||||
double offsetY = _calculateOffsetY(event.year);
|
||||
return _EventMarker(offsetY,
|
||||
isSelected: event == selectedEvent, semanticLabel: '${event.year}: ${event.description}');
|
||||
}).toList();
|
||||
@ -73,13 +74,33 @@ class _EventMarkersState extends State<_EventMarkers> {
|
||||
padding: EdgeInsets.only(left: 75),
|
||||
child: SizedBox(
|
||||
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.
|
||||
@ -99,23 +120,31 @@ class _EventMarker extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment(0, -1 + offset * 2),
|
||||
child: Semantics(
|
||||
label: semanticLabel,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
height: 30,
|
||||
child: AnimatedContainer(
|
||||
width: isSelected ? 6 : 2,
|
||||
height: isSelected ? 6 : 2,
|
||||
curve: Curves.easeOutBack,
|
||||
duration: $styles.times.med,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
color: $styles.colors.accent1,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: $styles.colors.accent1.withOpacity(isSelected ? .5 : 0), spreadRadius: 3, blurRadius: 3),
|
||||
],
|
||||
// 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(
|
||||
label: semanticLabel,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
height: 30,
|
||||
child: AnimatedContainer(
|
||||
width: isSelected ? 6 : 2,
|
||||
height: isSelected ? 6 : 2,
|
||||
curve: Curves.easeOutBack,
|
||||
duration: $styles.times.med,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
color: $styles.colors.accent1,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: $styles.colors.accent1.withOpacity(isSelected ? .5 : 0), spreadRadius: 3, blurRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -9,11 +9,13 @@ class _ScrollingViewport extends StatefulWidget {
|
||||
required this.minSize,
|
||||
required this.maxSize,
|
||||
required this.selectedWonder,
|
||||
this.onYearChanged,
|
||||
}) : super(key: key);
|
||||
final double minSize;
|
||||
final double maxSize;
|
||||
final ScrollController scroller;
|
||||
final WonderType? selectedWonder;
|
||||
final void Function(int year)? onYearChanged;
|
||||
final void Function(_ScrollingViewportController controller)? onInit;
|
||||
|
||||
@override
|
||||
@ -53,21 +55,10 @@ class _ScalingViewportState extends State<_ScrollingViewport> {
|
||||
// Fade in entire view when first shown
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
/// Main scrolling area, holds the year markers, and the [WondersTimelineBuilder]
|
||||
Expanded(
|
||||
child: _buildScrollingArea(context),
|
||||
),
|
||||
Gap($styles.insets.xs),
|
||||
// Main content area
|
||||
_buildScrollingArea(context).animate().fadeIn(),
|
||||
|
||||
/// Era Text (classical, modern etc)
|
||||
_buildAnimatedEraText(context),
|
||||
Gap($styles.insets.xs),
|
||||
],
|
||||
).animate().fadeIn(),
|
||||
|
||||
/// Dashed line with a year that changes as we scroll
|
||||
// Dashed line with a year that changes as we scroll
|
||||
IgnorePointer(
|
||||
ignoringSemantics: false,
|
||||
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) {
|
||||
// Builds a TimelineSection, and passes it the currently selected yr based on scroll position.
|
||||
// Rebuilds when timeline is scrolled.
|
||||
|
@ -12,6 +12,10 @@ class _ScrollingViewportController extends ChangeNotifier {
|
||||
late BoxConstraints _constraints;
|
||||
_ScrollingViewport get widget => state.widget;
|
||||
ScrollController get scroller => widget.scroller;
|
||||
late final ValueNotifier<int> _currentYr = ValueNotifier(startYr)
|
||||
..addListener(
|
||||
() => state.widget.onYearChanged?.call(_currentYr.value),
|
||||
);
|
||||
|
||||
void init() {
|
||||
scheduleMicrotask(() {
|
||||
@ -22,10 +26,13 @@ class _ScrollingViewportController extends ChangeNotifier {
|
||||
final pos = calculateScrollPosFromYear(data.startYr);
|
||||
scroller.jumpTo(pos - 200);
|
||||
scroller.animateTo(pos, duration: 1.35.seconds, curve: Curves.easeOutCubic);
|
||||
scroller.addListener(_updateCurrentYear);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCurrentYear() => _currentYr.value = calculateYearFromScrollPos();
|
||||
|
||||
/// Allows ancestors to set zoom directly
|
||||
void setZoom(double d) {
|
||||
// ignore: invalid_use_of_protected_member
|
||||
|
@ -57,10 +57,21 @@ class _YearMarker extends StatelessWidget {
|
||||
return ExcludeSemantics(
|
||||
child: Align(
|
||||
alignment: Alignment(0, -1 + offset * 2),
|
||||
child: FractionalTranslation(
|
||||
translation: Offset(0, 0),
|
||||
child: Text('${yr.abs()}', style: $styles.text.body.copyWith(color: Colors.white, height: 1)),
|
||||
|
||||
// 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(
|
||||
translation: Offset(0, -.5),
|
||||
child: Text('${yr.abs()}', style: $styles.text.body.copyWith(color: Colors.white, height: 1))),
|
||||
),
|
||||
),
|
||||
//child:,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user