Smooth rotation animation for menu ring
200ms ease-out animation when scrolling through icons. Selected icon grows smoothly, previous shrinks. Accent ring and label appear after animation completes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa35c66939
commit
8dea0c3854
1 changed files with 66 additions and 12 deletions
|
|
@ -2,26 +2,38 @@ import Toybox.Application;
|
|||
import Toybox.Graphics;
|
||||
import Toybox.Lang;
|
||||
import Toybox.Math;
|
||||
import Toybox.Timer;
|
||||
import Toybox.WatchUi;
|
||||
|
||||
// Rotating 10-icon ring modelled after the watch Controls menu.
|
||||
// Selection point is fixed at the 3 o'clock position (START/STOP
|
||||
// button height). UP/DOWN input rotates the ring.
|
||||
// button height). UP/DOWN input rotates the ring with a smooth
|
||||
// animation.
|
||||
class MenuView extends WatchUi.View {
|
||||
|
||||
const STORAGE_KEY = "menu_idx";
|
||||
const ANIM_DURATION_MS = 200;
|
||||
const ANIM_TICK_MS = 16; // ~60fps
|
||||
|
||||
private var _items as Array<Dictionary>;
|
||||
private var _selectedIndex as Number = 0;
|
||||
private var _bitmaps as Dictionary = {};
|
||||
|
||||
// Animation state.
|
||||
private var _animTimer as Timer.Timer;
|
||||
private var _animFrom as Number = 0; // previous index
|
||||
private var _animProgress as Float = 1.0; // 0.0 → 1.0, 1.0 = done
|
||||
private var _animStartMs as Number = 0;
|
||||
|
||||
function initialize() {
|
||||
View.initialize();
|
||||
_items = Config.menuItems();
|
||||
_animTimer = new Timer.Timer();
|
||||
var stored = Application.Storage.getValue(STORAGE_KEY);
|
||||
if (stored instanceof Number && stored >= 0 && stored < _items.size()) {
|
||||
_selectedIndex = stored;
|
||||
}
|
||||
_animFrom = _selectedIndex;
|
||||
}
|
||||
|
||||
function onLayout(dc as Dc) as Void {
|
||||
|
|
@ -32,14 +44,31 @@ class MenuView extends WatchUi.View {
|
|||
}
|
||||
|
||||
function rotateNext() as Void {
|
||||
_startAnim(_selectedIndex);
|
||||
_selectedIndex = (_selectedIndex + 1) % _items.size();
|
||||
Application.Storage.setValue(STORAGE_KEY, _selectedIndex);
|
||||
WatchUi.requestUpdate();
|
||||
}
|
||||
|
||||
function rotatePrev() as Void {
|
||||
_startAnim(_selectedIndex);
|
||||
_selectedIndex = (_selectedIndex - 1 + _items.size()) % _items.size();
|
||||
Application.Storage.setValue(STORAGE_KEY, _selectedIndex);
|
||||
}
|
||||
|
||||
private function _startAnim(fromIndex as Number) as Void {
|
||||
_animFrom = fromIndex;
|
||||
_animProgress = 0.0;
|
||||
_animStartMs = System.getTimer();
|
||||
_animTimer.start(method(:_animTick), ANIM_TICK_MS, true);
|
||||
}
|
||||
|
||||
function _animTick() as Void {
|
||||
var elapsed = System.getTimer() - _animStartMs;
|
||||
_animProgress = elapsed.toFloat() / ANIM_DURATION_MS.toFloat();
|
||||
if (_animProgress >= 1.0) {
|
||||
_animProgress = 1.0;
|
||||
_animTimer.stop();
|
||||
}
|
||||
WatchUi.requestUpdate();
|
||||
}
|
||||
|
||||
|
|
@ -57,15 +86,38 @@ class MenuView extends WatchUi.View {
|
|||
var baseSize = LayoutMetrics.iconSize(dc);
|
||||
var selSize = LayoutMetrics.selectedIconSize(dc);
|
||||
var n = _items.size();
|
||||
|
||||
var selectionAngle = Config.SELECTION_ANGLE_DEG * Math.PI / 180.0;
|
||||
var step = 2.0 * Math.PI / n;
|
||||
|
||||
// Ease-out for smooth deceleration.
|
||||
var t = _animProgress;
|
||||
var ease = 1.0 - (1.0 - t) * (1.0 - t);
|
||||
|
||||
// Interpolate the rotation offset between old and new index.
|
||||
var fromOffset = _animFrom.toFloat();
|
||||
var toOffset = _selectedIndex.toFloat();
|
||||
// Handle wrap-around (e.g. 9→0 or 0→9).
|
||||
var diff = toOffset - fromOffset;
|
||||
if (diff > n / 2.0) { diff = diff - n; }
|
||||
if (diff < -n / 2.0) { diff = diff + n; }
|
||||
var currentOffset = fromOffset + diff * ease;
|
||||
|
||||
for (var i = 0; i < n; i++) {
|
||||
var offset = i - _selectedIndex;
|
||||
var angle = selectionAngle + (2.0 * Math.PI * offset) / n;
|
||||
var angle = selectionAngle + (i.toFloat() - currentOffset) * step;
|
||||
var x = (cx + radius * Math.cos(angle)).toNumber();
|
||||
var y = (cy + radius * Math.sin(angle)).toNumber();
|
||||
|
||||
// Smoothly scale selected icon.
|
||||
var isSelected = (i == _selectedIndex);
|
||||
var targetSize = isSelected ? selSize : baseSize;
|
||||
var wasSelected = (i == _animFrom);
|
||||
var targetSize = baseSize;
|
||||
if (isSelected && wasSelected) {
|
||||
targetSize = selSize;
|
||||
} else if (isSelected) {
|
||||
targetSize = (baseSize + (selSize - baseSize) * ease).toNumber();
|
||||
} else if (wasSelected) {
|
||||
targetSize = (selSize - (selSize - baseSize) * ease).toNumber();
|
||||
}
|
||||
|
||||
// Colored circle behind icon.
|
||||
var color = _items[i][:color] as Number;
|
||||
|
|
@ -78,20 +130,22 @@ class MenuView extends WatchUi.View {
|
|||
_drawScaledIcon(dc, bmp, x, y, targetSize);
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
// Accent ring on selected item (also animated).
|
||||
if (isSelected && ease > 0.5) {
|
||||
dc.setPenWidth(LayoutMetrics.accentPenWidth(dc));
|
||||
dc.setColor(Config.COLOR_ACCENT, Config.COLOR_BG);
|
||||
dc.drawCircle(x, y, targetSize / 2 + 5);
|
||||
}
|
||||
}
|
||||
|
||||
var lines = _items[_selectedIndex][:lines] as Array<String>;
|
||||
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
|
||||
TextUtils.drawCentered(dc, lines, cx, cy, Graphics.FONT_TINY);
|
||||
// Only show label when animation is done to avoid flicker.
|
||||
if (_animProgress >= 1.0) {
|
||||
var lines = _items[_selectedIndex][:lines] as Array<String>;
|
||||
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
|
||||
TextUtils.drawCentered(dc, lines, cx, cy, Graphics.FONT_TINY);
|
||||
}
|
||||
}
|
||||
|
||||
// Scales a bitmap to the requested pixel size and draws it centered
|
||||
// at (cx, cy). Uses drawBitmap2 so icons stay crisp on any resolution.
|
||||
private function _drawScaledIcon(dc as Dc, bmp as BitmapResource,
|
||||
cx as Number, cy as Number,
|
||||
targetSize as Number) as Void {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue