diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index 6e7e9b3816..00e5d163be 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -24,6 +24,7 @@ package com.codename1.ui; import com.codename1.impl.CodenameOneImplementation; +import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.animations.ComponentAnimation; import com.codename1.ui.animations.Motion; import com.codename1.ui.animations.Transition; @@ -4497,7 +4498,7 @@ static class MorphAnimation extends ComponentAnimation { private Component scrollTo; public MorphAnimation(Container thisContainer, int duration, Motion[][] motions) { - startTime = System.currentTimeMillis(); + startTime = AnimationTime.now(); this.duration = duration; if (Motion.isSlowMotion()) { this.duration *= 50; @@ -4566,7 +4567,7 @@ protected void updateState() { thisContainer.setSmoothScrolling(s); } thisContainer.repaint(); - if (System.currentTimeMillis() - startTime >= duration) { + if (AnimationTime.now() - startTime >= duration) { setEnableLayoutOnPaint(true); thisContainer.dontRecurseContainer = false; Form f = thisContainer.getComponentForm(); diff --git a/CodenameOne/src/com/codename1/ui/Image.java b/CodenameOne/src/com/codename1/ui/Image.java index 9f88e0a58e..ee885d6d6b 100644 --- a/CodenameOne/src/com/codename1/ui/Image.java +++ b/CodenameOne/src/com/codename1/ui/Image.java @@ -27,6 +27,7 @@ import com.codename1.io.FileSystemStorage; import com.codename1.io.Log; import com.codename1.io.Util; +import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.events.ActionSource; @@ -1969,10 +1970,10 @@ public boolean isAnimation() { /// `true` if the animation state changed. public boolean animate() { if (imageTime == -1) { - imageTime = System.currentTimeMillis(); + imageTime = AnimationTime.now(); } boolean val = Display.impl.animateImage(image, imageTime); - imageTime = System.currentTimeMillis(); + imageTime = AnimationTime.now(); return val; } diff --git a/CodenameOne/src/com/codename1/ui/Label.java b/CodenameOne/src/com/codename1/ui/Label.java index 7b386bf0b8..b4a075d0c7 100644 --- a/CodenameOne/src/com/codename1/ui/Label.java +++ b/CodenameOne/src/com/codename1/ui/Label.java @@ -29,6 +29,7 @@ import com.codename1.ui.TextSelection.Span; import com.codename1.ui.TextSelection.Spans; import com.codename1.ui.TextSelection.TextSelectionSupport; +import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.geom.Dimension; @@ -1089,7 +1090,7 @@ public void startTicker(long delay, boolean rightToLeft) { parent.registerAnimatedInternal(this); } } - tickerStartTime = System.currentTimeMillis(); + tickerStartTime = AnimationTime.now(); tickerDelay = delay; tickerRunning = true; this.rightToLeft = rightToLeft; @@ -1169,8 +1170,8 @@ public boolean animate() { return false; } boolean animateTicker = false; - if (tickerRunning && tickerStartTime + tickerDelay < System.currentTimeMillis()) { - tickerStartTime = System.currentTimeMillis(); + if (tickerRunning && tickerStartTime + tickerDelay < AnimationTime.now()) { + tickerStartTime = AnimationTime.now(); if (rightToLeft) { shiftText -= Display.getInstance().convertToPixels(shiftMillimeters); if (shiftText + getStringWidth(getStyle().getFont()) < 0) { diff --git a/CodenameOne/src/com/codename1/ui/animations/AnimationTime.java b/CodenameOne/src/com/codename1/ui/animations/AnimationTime.java new file mode 100644 index 0000000000..5af6fc7388 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/animations/AnimationTime.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores + * CA 94065 USA or visit www.oracle.com if you need additional information or + * have any questions. + */ +package com.codename1.ui.animations; + +/// Pluggable time source for animations across the framework. Defaults to +/// `System.currentTimeMillis()` but can be overridden with an explicit value to +/// support deterministic playback (e.g. UI tests), to advance animations at a +/// custom pace (slow-motion, fast-forward), or to step through animation frames +/// manually. +/// +/// All methods are static and the class holds only primitive state to keep the +/// hot path cheap - `now()` is invoked from every animation tick. +/// +/// @author Shai Almog +public final class AnimationTime { + private static long overrideTime; + private static boolean overridden; + + private AnimationTime() { + } + + /// Returns the current animation time in milliseconds. Returns + /// `System.currentTimeMillis()` unless an override has been set via + /// [setTime(long)][#setTime(long)]. + /// + /// #### Returns + /// + /// the current animation time in milliseconds + public static long now() { + if (overridden) { + return overrideTime; + } + return System.currentTimeMillis(); + } + + /// Overrides the value returned by [now()][#now()]. Once set, every animation + /// reading the clock will see the same `time` value until either this method + /// is called again or [reset()][#reset()] is invoked. Advancing animations + /// while overridden requires repeatedly calling this method with increasing + /// values. + /// + /// #### Parameters + /// + /// - `time`: the time in milliseconds that [now()][#now()] should return + public static void setTime(long time) { + overrideTime = time; + overridden = true; + } + + /// Clears any override and restores [now()][#now()] to delegate to + /// `System.currentTimeMillis()`. + public static void reset() { + overridden = false; + } + + /// Returns true when an override time is currently active. + /// + /// #### Returns + /// + /// true when [now()][#now()] is returning an overridden value + public static boolean isOverridden() { + return overridden; + } +} diff --git a/CodenameOne/src/com/codename1/ui/animations/Motion.java b/CodenameOne/src/com/codename1/ui/animations/Motion.java index 5149379b04..987c816b97 100644 --- a/CodenameOne/src/com/codename1/ui/animations/Motion.java +++ b/CodenameOne/src/com/codename1/ui/animations/Motion.java @@ -30,8 +30,9 @@ /// another. This class can be subclassed to implement any motion equation for /// appropriate physics effects. /// -/// This class relies on the System.currentTimeMillis() method to provide -/// transitions between coordinates. The motion can be subclassed to provide every +/// This class relies on [AnimationTime.now()][AnimationTime#now()] to provide +/// transitions between coordinates, allowing the underlying clock to be +/// overridden for deterministic playback or custom animation pacing. The motion can be subclassed to provide every /// type of motion feel from parabolic motion to spline and linear motion. The default /// implementation provides a simple algorithm giving the feel of acceleration and /// deceleration. @@ -357,7 +358,7 @@ public static Motion createDecelerationMotionFrom(Motion motion, int maxDestinat motion.destinationValue < motion.sourceValue ? Math.min(motion.destinationValue, maxDestinationValue) : Math.max(motion.destinationValue, maxDestinationValue), - (int) Math.min(maxDuration, motion.duration - (System.currentTimeMillis() - motion.startTime)) + (int) Math.min(maxDuration, motion.duration - (AnimationTime.now() - motion.startTime)) ); } @@ -396,7 +397,7 @@ public static Motion createExponentialDecayMotion(int sourceValue, int maxValue, /// Sends the motion to the end time instantly which is useful for flushing an animation public void finish() { if (!isFinished()) { - startTime = System.currentTimeMillis() - duration; + startTime = AnimationTime.now() - duration; currentMotionTime = -1; previousCurrentMotionTime = -1; } @@ -404,17 +405,17 @@ public void finish() { /// Sets the start time to the current time public void start() { - startTime = System.currentTimeMillis(); + startTime = AnimationTime.now(); } /// Returns the current time within the motion relative to start time /// /// #### Returns /// - /// long value representing System.currentTimeMillis() - startTime + /// long value representing AnimationTime.now() - startTime public long getCurrentMotionTime() { if (currentMotionTime < 0) { - return System.currentTimeMillis() - startTime; + return AnimationTime.now() - startTime; } return currentMotionTime; } @@ -444,7 +445,7 @@ public boolean isDecayMotion() { /// /// #### Returns /// - /// true if System.currentTimeMillis() > duration + startTime or the last returned value is the destination value + /// true if AnimationTime.now() > duration + startTime or the last returned value is the destination value public boolean isFinished() { return getCurrentMotionTime() > duration || destinationValue == lastReturnedValue || (EXPONENTIAL_DECAY == motionType && previousLastReturnedValue[0] == lastReturnedValue); } diff --git a/CodenameOne/src/com/codename1/ui/animations/Timeline.java b/CodenameOne/src/com/codename1/ui/animations/Timeline.java index 205b64689f..c105408beb 100644 --- a/CodenameOne/src/com/codename1/ui/animations/Timeline.java +++ b/CodenameOne/src/com/codename1/ui/animations/Timeline.java @@ -145,7 +145,7 @@ public void setTime(int time) { if (!pause) { if (time >= 0 && time <= duration) { this.time = time; - currentTime = System.currentTimeMillis(); + currentTime = AnimationTime.now(); } } } @@ -161,11 +161,11 @@ public boolean isAnimation() { public boolean animate() { if (!pause) { if (currentTime < 0) { - currentTime = System.currentTimeMillis(); + currentTime = AnimationTime.now(); setTime(0); return true; } else { - long newCurrentTime = System.currentTimeMillis(); + long newCurrentTime = AnimationTime.now(); if (newCurrentTime - currentTime >= animationDelay) { int newTime = (int) (time + (newCurrentTime - currentTime)); currentTime = newCurrentTime; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/animations/AnimationTimeTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/animations/AnimationTimeTest.java new file mode 100644 index 0000000000..070fe76841 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/animations/AnimationTimeTest.java @@ -0,0 +1,115 @@ +package com.codename1.ui.animations; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.geom.Dimension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AnimationTimeTest extends UITestBase { + + @AfterEach + void resetClock() { + AnimationTime.reset(); + } + + @Test + void nowDefaultsToSystemClock() { + long before = System.currentTimeMillis(); + long now = AnimationTime.now(); + long after = System.currentTimeMillis(); + assertFalse(AnimationTime.isOverridden()); + assertTrue(now >= before && now <= after); + } + + @Test + void setTimeOverridesNow() { + AnimationTime.setTime(123_456L); + assertTrue(AnimationTime.isOverridden()); + assertEquals(123_456L, AnimationTime.now()); + assertEquals(123_456L, AnimationTime.now()); + } + + @Test + void setTimeAcceptsZero() { + AnimationTime.setTime(0L); + assertTrue(AnimationTime.isOverridden()); + assertEquals(0L, AnimationTime.now()); + } + + @Test + void setTimeAcceptsNegative() { + AnimationTime.setTime(-100L); + assertTrue(AnimationTime.isOverridden()); + assertEquals(-100L, AnimationTime.now()); + } + + @Test + void resetRestoresSystemClock() { + AnimationTime.setTime(42L); + assertEquals(42L, AnimationTime.now()); + + AnimationTime.reset(); + assertFalse(AnimationTime.isOverridden()); + + long before = System.currentTimeMillis(); + long now = AnimationTime.now(); + long after = System.currentTimeMillis(); + assertTrue(now >= before && now <= after); + } + + @Test + void motionHonorsOverriddenClock() { + AnimationTime.setTime(1000L); + Motion m = Motion.createLinearMotion(0, 100, 1000); + m.start(); + assertEquals(0L, m.getCurrentMotionTime()); + + AnimationTime.setTime(1500L); + assertEquals(500L, m.getCurrentMotionTime()); + assertEquals(50, m.getValue()); + + // advance past duration so isFinished() trips on the time check + AnimationTime.setTime(2001L); + assertTrue(m.isFinished()); + assertEquals(100, m.getValue()); + } + + @Test + void motionFinishUsesOverriddenClock() { + AnimationTime.setTime(5000L); + Motion m = Motion.createLinearMotion(0, 200, 1000); + m.start(); + AnimationTime.setTime(5100L); + assertFalse(m.isFinished()); + + m.finish(); + // finish() rewinds startTime to (now - duration); reading the value latches + // lastReturnedValue to destinationValue, which makes isFinished() true. + assertEquals(200, m.getValue()); + assertTrue(m.isFinished()); + } + + @Test + void timelineAnimateHonorsOverriddenClock() { + AnimationTime.setTime(10_000L); + Timeline timeline = Timeline.createTimeline(1000, new AnimationObject[0], new Dimension(1, 1)); + timeline.setAnimationDelay(0); + + // first animate() seeds the clock and sets time to 0 + assertTrue(timeline.animate()); + assertEquals(0, timeline.getTime()); + + // advance the clock and re-animate; timeline should advance by the same delta + AnimationTime.setTime(10_250L); + assertTrue(timeline.animate()); + assertEquals(250, timeline.getTime()); + + AnimationTime.setTime(10_750L); + assertTrue(timeline.animate()); + assertEquals(750, timeline.getTime()); + } +}