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: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,26 +58,32 @@ class _TimelineScreenState extends State<TimelineScreen> {
|
|||||||
|
|
||||||
/// Vertically scrolling timeline, manages a ScrollController.
|
/// Vertically scrolling timeline, manages a ScrollController.
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: _ScrollingViewport(
|
||||||
children: [
|
scroller: _scroller,
|
||||||
/// The timeline content itself
|
minSize: minSize,
|
||||||
_ScrollingViewport(
|
maxSize: maxSize,
|
||||||
scroller: _scroller,
|
selectedWonder: widget.type,
|
||||||
minSize: minSize,
|
onYearChanged: _handleViewportYearChanged,
|
||||||
maxSize: maxSize,
|
|
||||||
selectedWonder: widget.type,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/// 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(
|
||||||
_scroller,
|
padding: EdgeInsets.symmetric(horizontal: $styles.insets.lg),
|
||||||
size: scrubberSize,
|
child: _BottomScrubber(
|
||||||
timelineMinSize: minSize,
|
_scroller,
|
||||||
selectedWonder: widget.type,
|
size: scrubberSize,
|
||||||
|
timelineMinSize: minSize,
|
||||||
|
selectedWonder: widget.type,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Gap($styles.insets.lg),
|
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(
|
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!] : [],
|
||||||
|
@ -30,6 +30,7 @@ class _DashedDividerWithYear extends StatelessWidget {
|
|||||||
shadows: $styles.shadows.textStrong,
|
shadows: $styles.shadows.textStrong,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Gap($styles.insets.xs),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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,23 +120,31 @@ 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),
|
||||||
child: Semantics(
|
// Use an OverflowBox wrapped in a zero-height sized box so that al
|
||||||
label: semanticLabel,
|
// This allows alignment-based positioning to be accurate even at the edges of the parent.
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
alignment: Alignment.center,
|
height: 0,
|
||||||
height: 30,
|
child: OverflowBox(
|
||||||
child: AnimatedContainer(
|
maxHeight: 30,
|
||||||
width: isSelected ? 6 : 2,
|
child: Semantics(
|
||||||
height: isSelected ? 6 : 2,
|
label: semanticLabel,
|
||||||
curve: Curves.easeOutBack,
|
child: Container(
|
||||||
duration: $styles.times.med,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
height: 30,
|
||||||
borderRadius: BorderRadius.circular(99),
|
child: AnimatedContainer(
|
||||||
color: $styles.colors.accent1,
|
width: isSelected ? 6 : 2,
|
||||||
boxShadow: [
|
height: isSelected ? 6 : 2,
|
||||||
BoxShadow(
|
curve: Curves.easeOutBack,
|
||||||
color: $styles.colors.accent1.withOpacity(isSelected ? .5 : 0), spreadRadius: 3, blurRadius: 3),
|
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.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.
|
||||||
|
@ -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
|
||||||
|
@ -57,10 +57,21 @@ class _YearMarker extends StatelessWidget {
|
|||||||
return ExcludeSemantics(
|
return ExcludeSemantics(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment(0, -1 + offset * 2),
|
alignment: Alignment(0, -1 + offset * 2),
|
||||||
child: FractionalTranslation(
|
|
||||||
translation: Offset(0, 0),
|
// Use an OverflowBox wrapped in a zero-height sized box so that al
|
||||||
child: Text('${yr.abs()}', style: $styles.text.body.copyWith(color: Colors.white, height: 1)),
|
// 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