diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/util/Animator.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/Animator.java new file mode 100644 index 00000000..67dfa94e --- /dev/null +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/Animator.java @@ -0,0 +1,299 @@ +/* + * Copyright 2020 FormDev Software GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.formdev.flatlaf.util; + +import java.util.ArrayList; +import javax.swing.Timer; + +/** + * Simple animator based on ideas and concepts from "Filthy Rich Clients" book + * and "Timing Framework" library. + * + * @author Karl Tauber + */ +public class Animator +{ + private int duration; + private int resolution = 10; + private Interpolator interpolator; + private final ArrayList targets = new ArrayList<>(); + + private boolean running; + private boolean hasBegun; + private boolean timeToStop; + private long startTime; + private Timer timer; + + /** + * Creates an animation that runs duration milliseconds. + * Use {@link #addTarget(TimingTarget)} to receive timing events + * and {@link #start()} to start the animation. + * + * @param duration the duration of the animation in milliseconds + */ + public Animator( int duration ) { + this( duration, null ); + } + + /** + * Creates an animation that runs duration milliseconds. + * Use {@link #start()} to start the animation. + * + * @param duration the duration of the animation in milliseconds + * @param target the target that receives timing events + */ + public Animator( int duration, TimingTarget target ) { + setDuration( duration ); + addTarget( target ); + } + + /** + * Returns the duration of the animation in milliseconds. + */ + public int getDuration() { + return duration; + } + + /** + * Sets the duration of the animation in milliseconds. + * + * @throws IllegalStateException if animation is running + * @throws IllegalArgumentException if duration is <= zero + */ + public void setDuration( int duration ) { + throwExceptionIfRunning(); + if( duration <= 0 ) + throw new IllegalArgumentException(); + this.duration = duration; + } + + /** + * Returns the resolution of the animation in milliseconds (default is 10). + * Resolution is the amount of time between timing events. + */ + public int getResolution() { + return resolution; + } + + /** + * Sets the resolution of the animation in milliseconds. + * + * @throws IllegalStateException if animation is running + * @throws IllegalArgumentException if resolution is <= zero + */ + public void setResolution( int resolution ) { + throwExceptionIfRunning(); + if( resolution <= 0 ) + throw new IllegalArgumentException(); + this.resolution = resolution; + } + + /** + * Returns the interpolator for the animation. + * Default is {@code null}, which means linear. + */ + public Interpolator getInterpolator() { + return interpolator; + } + + /** + * Sets the interpolator for the animation. + * + * @throws IllegalStateException if animation is running + */ + public void setInterpolator( Interpolator interpolator ) { + throwExceptionIfRunning(); + this.interpolator = interpolator; + } + + /** + * Adds a target to the animation that receives timing events. + * + * @param target the target that receives timing events + */ + public void addTarget( TimingTarget target ) { + if( target == null ) + return; + + synchronized( targets ) { + if( !targets.contains( target ) ) + targets.add( target ); + } + } + + /** + * Removes a target from the animation. + * + * @param target the target that should be removed + */ + public void removeTarget( TimingTarget target ) { + synchronized( targets ) { + targets.remove( target ); + } + } + + /** + * Starts the animation. + * + * @throws IllegalStateException if animation is running + */ + public void start() { + throwExceptionIfRunning(); + + running = true; + hasBegun = false; + timeToStop = false; + startTime = System.nanoTime() / 1000000; + + timer = new Timer( resolution, e -> { + if( !hasBegun ) { + begin(); + hasBegun = true; + } + + timingEvent( getTimingFraction() ); + } ); + timer.setInitialDelay( 0 ); + timer.start(); + } + + /** + * Stops the animation before it normally ends. + * Invokes {@link TimingTarget#end()} on timing targets. + */ + public void stop() { + stop( false ); + } + + /** + * Cancels the animation before it normally ends. + * Does not invoke {@link TimingTarget#end()} on timing targets. + */ + public void cancel() { + stop( true ); + } + + private void stop( boolean cancel ) { + if( timer != null ) { + timer.stop(); + timer = null; + } + + if( !cancel ) + end(); + + running = false; + timeToStop = false; + } + + /** + * Returns whether this animation is running. + */ + public boolean isRunning() { + return running; + } + + private float getTimingFraction() { + long currentTime = System.nanoTime() / 1000000; + long elapsedTime = currentTime - startTime; + timeToStop = (elapsedTime >= duration); + + float fraction = clampFraction( (float) elapsedTime / duration ); + if( interpolator != null ) + fraction = clampFraction( interpolator.interpolate( fraction ) ); + return fraction; + } + + private float clampFraction( float fraction ) { + if( fraction < 0 ) + return 0; + if( fraction > 1 ) + return 1; + return fraction; + } + + private void timingEvent( float fraction ) { + synchronized( targets ) { + for( TimingTarget target : targets ) + target.timingEvent( fraction ); + } + + if( timeToStop ) + stop(); + } + + private void begin() { + synchronized( targets ) { + for( TimingTarget target : targets ) + target.begin(); + } + } + + private void end() { + synchronized( targets ) { + for( TimingTarget target : targets ) + target.end(); + } + } + + private void throwExceptionIfRunning() { + if( isRunning() ) + throw new IllegalStateException(); + } + + //---- interface TimingTarget --------------------------------------------- + + /** + * Animation callbacks. + */ + @FunctionalInterface + public interface TimingTarget { + /** + * Invoked multiple times while animation is running. + * + * @param fraction the percent (0 to 1) elapsed of the current animation cycle + */ + void timingEvent( float fraction ); + + /** + * Invoked when the animation begins. + */ + default void begin() {} + + /** + * Invoked when the animation ends. + */ + default void end() {} + } + + //---- interface Interpolator --------------------------------------------- + + /** + * Interpolator used by animation to change timing fraction. E.g. for easing. + */ + @FunctionalInterface + public interface Interpolator { + /** + * Interpolate the given fraction and returns a new fraction. + * Both fractions are in range [0, 1]. + * + * @param fraction the percent (0 to 1) elapsed of the current animation cycle + * @return new fraction in range [0, 1] + */ + float interpolate( float fraction ); + } +} diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/util/CubicBezierEasing.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/CubicBezierEasing.java new file mode 100644 index 00000000..e714a209 --- /dev/null +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/CubicBezierEasing.java @@ -0,0 +1,105 @@ +/* + * Copyright 2020 FormDev Software GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.formdev.flatlaf.util; + +/** + * An interpolator for {@link Animator} that uses a cubic bezier curve. + * + * @author Karl Tauber + */ +public class CubicBezierEasing + implements Animator.Interpolator +{ + // common cubic-bezier easing functions (same as in CSS) + // https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function + public static final CubicBezierEasing EASE = new CubicBezierEasing( 0.25f, 0.1f, 0.25f, 1f ); + public static final CubicBezierEasing EASE_IN = new CubicBezierEasing( 0.42f, 0f, 1f, 1f ); + public static final CubicBezierEasing EASE_IN_OUT = new CubicBezierEasing( 0.42f, 0f, 0.58f, 1f ); + public static final CubicBezierEasing EASE_OUT = new CubicBezierEasing( 0f, 0f, 0.58f, 1f ); + + private final float x1; + private final float y1; + private final float x2; + private final float y2; + + /** + * Creates a cubic bezier easing interpolator with the given control points. + * The start point of the cubic bezier curve is always 0,0 and the end point 1,1. + * + * @param x1 the x coordinate of the first control point in range [0, 1] + * @param y1 the y coordinate of the first control point in range [0, 1] + * @param x2 the x coordinate of the second control point in range [0, 1] + * @param y2 the y coordinate of the second control point in range [0, 1] + */ + public CubicBezierEasing( float x1, float y1, float x2, float y2 ) { + if( x1 < 0 || x1 > 1 || y1 < 0 || y1 > 1 || + x2 < 0 || x2 > 1 || y2 < 0 || y2 > 1 ) + throw new IllegalArgumentException( "control points must be in range [0, 1]"); + + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + } + + @Override + public float interpolate( float fraction ) { + if( fraction <= 0 || fraction >= 1 ) + return fraction; + + // use binary search + float low = 0; + float high = 1; + while( true ) { + float mid = (low + high) / 2; + float estimate = cubicBezier( mid, x1, x2 ); + if( Math.abs( fraction - estimate ) < 0.0005f ) + return cubicBezier( mid, y1, y2 ); + if( estimate < fraction ) + low = mid; + else + high = mid; + } + } + + /** + * Computes the x or y point on a cubic bezier curve for a given t value. + * + * https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B%C3%A9zier_curves + * + * The general cubic bezier formula is: + * x = b0*x0 + b1*x1 + b2*x2 + b3*x3 + * y = b0*y0 + b1*y1 + b2*y2 + b3*y3 + * + * where: + * b0 = (1-t)^3 + * b1 = 3 * t * (1-t)^2 + * b2 = 3 * t^2 * (1-t) + * b3 = t^3 + * + * x0,y0 is always 0,0 and x3,y3 is 1,1, so we can simplify to: + * x = b1*x1 + b2*x2 + b3 + * y = b1*x1 + b2*x2 + b3 + */ + private static float cubicBezier( float t, float xy1, float xy2 ) { + float invT = (1 - t); + float b1 = 3 * t * (invT * invT); + float b2 = 3 * (t * t) * invT; + float b3 = t * t * t; + return (b1 * xy1) + (b2 * xy2) + b3; + } +} diff --git a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatorTest.java b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatorTest.java new file mode 100644 index 00000000..5aacd963 --- /dev/null +++ b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatorTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020 FormDev Software GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.formdev.flatlaf.testing; + +import java.awt.*; +import javax.swing.*; +import com.formdev.flatlaf.util.Animator; +import com.formdev.flatlaf.util.CubicBezierEasing; +import net.miginfocom.swing.*; + +/** + * @author Karl Tauber + */ +public class FlatAnimatorTest + extends FlatTestPanel +{ + private Animator linearAnimator; + private Animator easeInOutAnimator; + + public static void main( String[] args ) { + SwingUtilities.invokeLater( () -> { + FlatTestFrame frame = FlatTestFrame.create( args, "FlatAnimatorTest" ); + frame.showFrame( FlatAnimatorTest::new ); + } ); + } + + FlatAnimatorTest() { + initComponents(); + } + + private void start() { + startLinear(); + startEaseInOut(); + } + + private void startLinear() { + if( linearAnimator != null ) { + linearAnimator.stop(); + linearAnimator.start(); + } else { + linearAnimator = new Animator( 1000, t -> { + linearScrollBar.setValue( Math.round( t * linearScrollBar.getMaximum() ) ); + } ); + linearAnimator.start(); + } + } + + private void startEaseInOut() { + if( easeInOutAnimator != null ) { + easeInOutAnimator.stop(); + easeInOutAnimator.start(); + } else { + easeInOutAnimator = new Animator( 1000, t -> { + easeInOutScrollBar.setValue( Math.round( t * easeInOutScrollBar.getMaximum() ) ); + } ); + easeInOutAnimator.setInterpolator( CubicBezierEasing.EASE_IN_OUT ); + easeInOutAnimator.start(); + } + } + + private void initComponents() { + // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents + JLabel label1 = new JLabel(); + linearScrollBar = new JScrollBar(); + JLabel label2 = new JLabel(); + easeInOutScrollBar = new JScrollBar(); + startButton = new JButton(); + + //======== this ======== + setLayout(new MigLayout( + "ltr,insets dialog,hidemode 3", + // columns + "[fill]" + + "[grow,fill]", + // rows + "[]" + + "[]" + + "[]")); + + //---- label1 ---- + label1.setText("Linear:"); + add(label1, "cell 0 0"); + + //---- linearScrollBar ---- + linearScrollBar.setOrientation(Adjustable.HORIZONTAL); + linearScrollBar.setBlockIncrement(1); + add(linearScrollBar, "cell 1 0"); + + //---- label2 ---- + label2.setText("Ease in out:"); + add(label2, "cell 0 1"); + + //---- easeInOutScrollBar ---- + easeInOutScrollBar.setOrientation(Adjustable.HORIZONTAL); + easeInOutScrollBar.setBlockIncrement(1); + add(easeInOutScrollBar, "cell 1 1"); + + //---- startButton ---- + startButton.setText("Start"); + startButton.addActionListener(e -> start()); + add(startButton, "cell 0 2"); + // JFormDesigner - End of component initialization //GEN-END:initComponents + } + + // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables + private JScrollBar linearScrollBar; + private JScrollBar easeInOutScrollBar; + private JButton startButton; + // JFormDesigner - End of variables declaration //GEN-END:variables +} diff --git a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatorTest.jfd b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatorTest.jfd new file mode 100644 index 00000000..bb92a129 --- /dev/null +++ b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatorTest.jfd @@ -0,0 +1,62 @@ +JFDML JFormDesigner: "7.0.2.0.298" Java: "14.0.2" encoding: "UTF-8" + +new FormModel { + contentType: "form/swing" + root: new FormRoot { + auxiliary() { + "JavaCodeGenerator.defaultVariableLocal": true + } + add( new FormContainer( "com.formdev.flatlaf.testing.FlatTestPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { + "$layoutConstraints": "ltr,insets dialog,hidemode 3" + "$columnConstraints": "[fill][grow,fill]" + "$rowConstraints": "[][][]" + } ) { + name: "this" + add( new FormComponent( "javax.swing.JLabel" ) { + name: "label1" + "text": "Linear:" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 0" + } ) + add( new FormComponent( "javax.swing.JScrollBar" ) { + name: "linearScrollBar" + "orientation": 0 + "blockIncrement": 1 + auxiliary() { + "JavaCodeGenerator.variableLocal": false + } + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 1 0" + } ) + add( new FormComponent( "javax.swing.JLabel" ) { + name: "label2" + "text": "Ease in out:" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 1" + } ) + add( new FormComponent( "javax.swing.JScrollBar" ) { + name: "easeInOutScrollBar" + "orientation": 0 + "blockIncrement": 1 + auxiliary() { + "JavaCodeGenerator.variableLocal": false + } + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 1 1" + } ) + add( new FormComponent( "javax.swing.JButton" ) { + name: "startButton" + "text": "Start" + auxiliary() { + "JavaCodeGenerator.variableLocal": false + } + addEvent( new FormEvent( "java.awt.event.ActionListener", "actionPerformed", "start", false ) ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 2" + } ) + }, new FormLayoutConstraints( null ) { + "location": new java.awt.Point( 0, 0 ) + "size": new java.awt.Dimension( 415, 350 ) + } ) + } +}