From b2245e2246a17348706fddb29be134e5d401b4aa Mon Sep 17 00:00:00 2001 From: Karl Tauber Date: Tue, 13 Jul 2021 19:43:07 +0200 Subject: [PATCH] AnimatedBorder added (for future animations) (issue #66) --- .../formdev/flatlaf/util/AnimatedBorder.java | 248 +++++++++++++++++ .../testing/FlatAnimatedBorderTest.java | 256 ++++++++++++++++++ .../testing/FlatAnimatedBorderTest.jfd | 76 ++++++ 3 files changed, 580 insertions(+) create mode 100644 flatlaf-core/src/main/java/com/formdev/flatlaf/util/AnimatedBorder.java create mode 100644 flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.java create mode 100644 flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.jfd diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/util/AnimatedBorder.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/AnimatedBorder.java new file mode 100644 index 00000000..add515a2 --- /dev/null +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/AnimatedBorder.java @@ -0,0 +1,248 @@ +/* + * Copyright 2021 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.awt.Component; +import java.awt.Graphics; +import javax.swing.JComponent; +import javax.swing.border.Border; +import com.formdev.flatlaf.util.Animator.Interpolator; + +/** + * Border that automatically animates painting on component value changes. + *

+ * {@link #getValue(Component)} returns the value of the component. + * If the value changes, then {@link #paintBorderAnimated(Component, Graphics, int, int, int, int, float)} + * is invoked multiple times with animated value (from old value to new value). + *

+ * Example for an animated border: + *

+ * private class AnimatedMinimalTestBorder
+ *     implements AnimatedBorder
+ * {
+ *     @Override
+ *     public void paintBorderAnimated( Component c, Graphics g, int x, int y, int width, int height, float animatedValue ) {
+ *         int lh = UIScale.scale( 2 );
+ *
+ *         g.setColor( Color.blue );
+ *         g.fillRect( x, y + height - lh, Math.round( width * animatedValue ), lh );
+ *     }
+ *
+ *     @Override
+ *     public float getValue( Component c ) {
+ *         return c.isFocusOwner() ? 1 : 0;
+ *     }
+ *
+ *     @Override
+ *     public Insets getBorderInsets( Component c ) {
+ *         return UIScale.scale( new Insets( 4, 4, 4, 4 ) );
+ *     }
+ *
+ *     @Override public boolean isBorderOpaque() { return false; }
+ * }
+ *
+ * // sample usage
+ * JTextField textField = new JTextField();
+ * textField.setBorder( new AnimatedMinimalTestBorder() );
+ * 
+ * + * Animation works only if the component passed to {@link #paintBorder(Component, Graphics, int, int, int, int)} + * is a instance of {@link JComponent}. + * A client property is set on the component to store the animation state. + * + * @author Karl Tauber + */ +public interface AnimatedBorder + extends Border +{ + @Override + default void paintBorder( Component c, Graphics g, int x, int y, int width, int height ) { + AnimationSupport.paintBorder( this, c, g, x, y, width, height ); + } + + /** + * Paints the border for the given animated value. + * + * @param c the component that this border belongs to + * @param g the graphics context + * @param x the x coordinate of the border + * @param y the y coordinate of the border + * @param width the width coordinate of the border + * @param height the height coordinate of the border + * @param animatedValue the animated value, which is either equal to what {@link #getValue(Component)} + * returned, or somewhere between the previous value and the latest value + * that {@link #getValue(Component)} returned + */ + void paintBorderAnimated( Component c, Graphics g, int x, int y, int width, int height, float animatedValue ); + + /** + * Gets the value of the component. + *

+ * This can be any value and depends on the component. + * If the value changes, then this class animates from the old value to the new one. + *

+ * For a text field this could be {@code 0} for not focused and {@code 1} for focused. + */ + float getValue( Component c ); + + /** + * Returns whether animation is enabled for this border (default is {@code true}). + */ + default boolean isAnimationEnabled() { + return true; + } + + /** + * Returns the duration of the animation in milliseconds (default is 150). + */ + default int getAnimationDuration() { + return 150; + } + + /** + * Returns the resolution of the animation in milliseconds (default is 10). + * Resolution is the amount of time between timing events. + */ + default int getAnimationResolution() { + return 10; + } + + /** + * Returns the interpolator for the animation. + * Default is {@link CubicBezierEasing#STANDARD_EASING}. + */ + default Interpolator getAnimationInterpolator() { + return CubicBezierEasing.STANDARD_EASING; + } + + /** + * Returns the client property key used to store the animation support. + */ + default Object getClientPropertyKey() { + return getClass(); + } + + //---- class AnimationSupport --------------------------------------------- + + /** + * Animation support class that stores the animation state and implements the animation. + */ + class AnimationSupport + { + private float startValue; + private float targetValue; + private float animatedValue; + private float fraction; + + private Animator animator; + + // last bounds of the border needed to repaint while animating + private int x; + private int y; + private int width; + private int height; + + public static void paintBorder( AnimatedBorder border, Component c, Graphics g, + int x, int y, int width, int height ) + { + if( !isAnimationEnabled( border, c ) ) { + // paint without animation if animation is disabled or + // component is not a JComponent and therefore does not support + // client properties, which are required to keep animation state + paintBorderImpl( border, c, g, x, y, width, height, null ); + return; + } + + JComponent jc = (JComponent) c; + Object key = border.getClientPropertyKey(); + AnimationSupport as = (AnimationSupport) jc.getClientProperty( key ); + if( as == null ) { + // painted first time --> do not animate, but remember current component value + as = new AnimationSupport(); + as.startValue = as.targetValue = as.animatedValue = border.getValue( c ); + jc.putClientProperty( key, as ); + } else { + // get component value + float value = border.getValue( c ); + + if( value != as.targetValue ) { + // value changed --> (re)start animation + + if( as.animator == null ) { + // create animator + AnimationSupport as2 = as; + as.animator = new Animator( border.getAnimationDuration(), fraction -> { + // check whether component was removed while animation is running + if( !c.isDisplayable() ) { + as2.animator.stop(); + return; + } + + // compute animated value + as2.animatedValue = as2.startValue + ((as2.targetValue - as2.startValue) * fraction); + as2.fraction = fraction; + + // repaint border + c.repaint( as2.x, as2.y, as2.width, as2.height ); + }, () -> { + as2.startValue = as2.animatedValue = as2.targetValue; + as2.animator = null; + } ); + } + + if( as.animator.isRunning() ) { + // if animation is still running, restart it from the current + // animated value to the new target value with reduced duration + as.animator.cancel(); + int duration2 = (int) (border.getAnimationDuration() * as.fraction); + if( duration2 > 0 ) + as.animator.setDuration( duration2 ); + as.startValue = as.animatedValue; + } else { + // new animation + as.animator.setDuration( border.getAnimationDuration() ); + as.animator.setResolution( border.getAnimationResolution() ); + as.animator.setInterpolator( border.getAnimationInterpolator() ); + + as.animatedValue = as.startValue; + } + + as.targetValue = value; + as.animator.start(); + } + } + + as.x = x; + as.y = y; + as.width = width; + as.height = height; + + paintBorderImpl( border, c, g, x, y, width, height, as ); + } + + private static void paintBorderImpl( AnimatedBorder border, Component c, Graphics g, + int x, int y, int width, int height, AnimationSupport as ) + { + float value = (as != null) ? as.animatedValue : border.getValue( c ); + border.paintBorderAnimated( c, g, x, y, width, height, value ); + } + + private static boolean isAnimationEnabled( AnimatedBorder border, Component c ) { + return Animator.useAnimation() && border.isAnimationEnabled() && c instanceof JComponent; + } + } +} diff --git a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.java b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.java new file mode 100644 index 00000000..abd8fcc5 --- /dev/null +++ b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.java @@ -0,0 +1,256 @@ +/* + * Copyright 2021 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.Color; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Insets; +import java.awt.geom.Rectangle2D; +import javax.swing.*; +import com.formdev.flatlaf.ui.FlatMarginBorder; +import com.formdev.flatlaf.ui.FlatUIUtils; +import com.formdev.flatlaf.util.AnimatedBorder; +import com.formdev.flatlaf.util.ColorFunctions; +import com.formdev.flatlaf.util.HiDPIUtils; +import com.formdev.flatlaf.util.UIScale; +import net.miginfocom.swing.*; + +/** + * @author Karl Tauber + */ +public class FlatAnimatedBorderTest + extends FlatTestPanel +{ + public static void main( String[] args ) { + SwingUtilities.invokeLater( () -> { + FlatTestFrame frame = FlatTestFrame.create( args, "FlatAnimatedBorderTest" ); + frame.showFrame( FlatAnimatedBorderTest::new ); + } ); + } + + FlatAnimatedBorderTest() { + initComponents(); + + textField5.setBorder( new AnimatedFocusFadeBorder() ); + textField6.setBorder( new AnimatedFocusFadeBorder() ); + + textField1.setBorder( new AnimatedMaterialBorder() ); + textField2.setBorder( new AnimatedMaterialBorder() ); + + textField4.setBorder( new AnimatedMinimalTestBorder() ); + } + + private void initComponents() { + // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents + label3 = new JLabel(); + textField5 = new JTextField(); + textField6 = new JTextField(); + label2 = new JLabel(); + textField1 = new JTextField(); + textField2 = new JTextField(); + label1 = new JLabel(); + textField4 = new JTextField(); + durationLabel = new JLabel(); + durationField = new JSpinner(); + + //======== this ======== + setLayout(new MigLayout( + "insets dialog,hidemode 3", + // columns + "[fill]", + // rows + "[]" + + "[]" + + "[]para" + + "[]" + + "[]" + + "[]para" + + "[]" + + "[]" + + "[grow]" + + "[]")); + + //---- label3 ---- + label3.setText("Fade:"); + add(label3, "cell 0 0"); + add(textField5, "cell 0 1"); + add(textField6, "cell 0 2"); + + //---- label2 ---- + label2.setText("Material:"); + add(label2, "cell 0 3"); + add(textField1, "cell 0 4"); + add(textField2, "cell 0 5"); + + //---- label1 ---- + label1.setText("Minimal:"); + add(label1, "cell 0 6"); + add(textField4, "cell 0 7"); + + //---- durationLabel ---- + durationLabel.setText("Duration:"); + add(durationLabel, "cell 0 9"); + + //---- durationField ---- + durationField.setModel(new SpinnerNumberModel(200, 100, null, 50)); + add(durationField, "cell 0 9"); + // JFormDesigner - End of component initialization //GEN-END:initComponents + } + + // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables + private JLabel label3; + private JTextField textField5; + private JTextField textField6; + private JLabel label2; + private JTextField textField1; + private JTextField textField2; + private JLabel label1; + private JTextField textField4; + private JLabel durationLabel; + private JSpinner durationField; + // JFormDesigner - End of variables declaration //GEN-END:variables + + //---- class AnimatedMaterialBorder --------------------------------------- + + /** + * Experimental text field border that: + * - animates focus indicator color and border width + */ + private class AnimatedFocusFadeBorder + extends FlatMarginBorder + implements AnimatedBorder + { + // needed because otherwise the empty paint method in superclass + // javax.swing.border.AbstractBorder would be used + @Override + public void paintBorder( Component c, Graphics g, int x, int y, int width, int height ) { + AnimationSupport.paintBorder( this, c, g, x, y, width, height ); + } + + @Override + public void paintBorderAnimated( Component c, Graphics g, int x, int y, int width, int height, float animatedValue ) { + FlatUIUtils.setRenderingHints( g ); + + // border width is 1 if not focused and 2 if focused + float lw = UIScale.scale( 1 + animatedValue ); + + // paint border + g.setColor( ColorFunctions.mix( Color.red, Color.lightGray, animatedValue ) ); + FlatUIUtils.paintComponentBorder( (Graphics2D) g, x, y, width, height, 0, lw, 0 ); + } + + @Override + public float getValue( Component c ) { + return FlatUIUtils.isPermanentFocusOwner( c ) ? 1 : 0; + } + + @Override + public int getAnimationDuration() { + return (Integer) durationField.getValue(); + } + } + + //---- class AnimatedMaterialBorder --------------------------------------- + + /** + * Experimental text field border that: + * - paint border only at bottom + * - animates focus indicator at bottom + */ + private class AnimatedMaterialBorder + extends FlatMarginBorder + implements AnimatedBorder + { + // needed because otherwise the empty paint method in superclass + // javax.swing.border.AbstractBorder would be used + @Override + public void paintBorder( Component c, Graphics g, int x, int y, int width, int height ) { + AnimationSupport.paintBorder( this, c, g, x, y, width, height ); + } + + @Override + public void paintBorderAnimated( Component c, Graphics g, int x, int y, int width, int height, float animatedValue ) { + FlatUIUtils.setRenderingHints( g ); + + // use paintAtScale1x() for consistent line thickness when scaled + HiDPIUtils.paintAtScale1x( (Graphics2D) g, x, y, width, height, + (g2d, x2, y2, width2, height2, scaleFactor) -> { + float lh = (float) (UIScale.scale( 1f ) * scaleFactor); + + g2d.setColor( Color.gray ); + g2d.fill( new Rectangle2D.Float( x2, y2 + height2 - lh, width2, lh ) ); + + if( animatedValue > 0 ) { + lh = (float) (UIScale.scale( 2f ) * scaleFactor); + int lw = Math.round( width2 * animatedValue ); + + g2d.setColor( Color.red ); + g2d.fill( new Rectangle2D.Float( x2 + (width2 - lw) / 2, y2 + height2 - lh, lw, lh ) ); + } + } ); + } + + @Override + public float getValue( Component c ) { + return FlatUIUtils.isPermanentFocusOwner( c ) ? 1 : 0; + } + + @Override + public int getAnimationDuration() { + return (Integer) durationField.getValue(); + } + } + + //---- class AnimatedMinimalTestBorder ------------------------------------ + + /** + * Minimal example for an animated border. + */ + private class AnimatedMinimalTestBorder + implements AnimatedBorder + { + @Override + public void paintBorderAnimated( Component c, Graphics g, int x, int y, int width, int height, float animatedValue ) { + int lh = UIScale.scale( 2 ); + + g.setColor( Color.blue ); + g.fillRect( x, y + height - lh, Math.round( width * animatedValue ), lh ); + } + + @Override + public float getValue( Component c ) { + return FlatUIUtils.isPermanentFocusOwner( c ) ? 1 : 0; + } + + @Override + public int getAnimationDuration() { + return (Integer) durationField.getValue(); + } + + @Override + public Insets getBorderInsets( Component c ) { + return UIScale.scale( new Insets( 4, 4, 4, 4 ) ); + } + + @Override + public boolean isBorderOpaque() { + return false; + } + } +} diff --git a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.jfd b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.jfd new file mode 100644 index 00000000..cae2e60d --- /dev/null +++ b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatAnimatedBorderTest.jfd @@ -0,0 +1,76 @@ +JFDML JFormDesigner: "7.0.4.0.360" Java: "16" encoding: "UTF-8" + +new FormModel { + contentType: "form/swing" + root: new FormRoot { + add( new FormContainer( "com.formdev.flatlaf.testing.FlatTestPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { + "$layoutConstraints": "insets dialog,hidemode 3" + "$columnConstraints": "[fill]" + "$rowConstraints": "[][][]para[][][]para[][][grow][]" + } ) { + name: "this" + add( new FormComponent( "javax.swing.JLabel" ) { + name: "label3" + "text": "Fade:" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 0" + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "textField5" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 1" + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "textField6" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 2" + } ) + add( new FormComponent( "javax.swing.JLabel" ) { + name: "label2" + "text": "Material:" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 3" + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "textField1" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 4" + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "textField2" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 5" + } ) + add( new FormComponent( "javax.swing.JLabel" ) { + name: "label1" + "text": "Minimal:" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 6" + } ) + add( new FormComponent( "javax.swing.JTextField" ) { + name: "textField4" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 7" + } ) + add( new FormComponent( "javax.swing.JLabel" ) { + name: "durationLabel" + "text": "Duration:" + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 9" + } ) + add( new FormComponent( "javax.swing.JSpinner" ) { + name: "durationField" + "model": new javax.swing.SpinnerNumberModel { + minimum: 100 + stepSize: 50 + value: 200 + } + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 9" + } ) + }, new FormLayoutConstraints( null ) { + "location": new java.awt.Point( 0, 0 ) + "size": new java.awt.Dimension( 405, 315 ) + } ) + } +}