Summary

JPanel上に時、分、秒用の2桁数字を上下に分割して表示し、それをAffineTransformを使用して垂直方向に縮小表示することでパネルの回転を表現するフリップ式時計を作成します。

Source Code Examples

class FlipPair extends JPanel {
  private static final Color CARD_UPPER_BG = new Color(45, 45, 45);
  private static final Color CARD_LOWER_BG = new Color(25, 25, 25);
  private static final Color TEXT_COLOR = new Color(230, 230, 230);
  private static final int SHADOW_MAX_ALPHA = 130;
  private static final int WIDTH = 80;
  private static final int HEIGHT = 100;
  private static final int SLIT_HEIGHT = 4;
  private static final int FONT_SIZE = 64;
  private static final String FONT_NAME = "Impact"; // "Impact" or "Arial"

  private int currentVal;
  private int nextVal;
  private double angle;
  private boolean isAnimating;
  private final Timer animTimer = new Timer(15, null);

  public FlipPair(int startValue) {
    super();
    this.currentVal = startValue;
    this.nextVal = startValue;
    animTimer.addActionListener(e -> {
      angle -= 15;
      if (angle <= 0) {
        angle = 0;
        isAnimating = false;
        currentVal = nextVal;
        animTimer.stop();
      }
      repaint();
    });
  }

  public void setValue(int newValue) {
    if (newValue != nextVal && !isAnimating) {
      nextVal = newValue;
      angle = 180;
      isAnimating = true;
      animTimer.start();
    }
  }

  @Override public Dimension getPreferredSize() {
    Dimension d = super.getPreferredSize();
    d.setSize(WIDTH, HEIGHT);
    return d;
  }

  @Override public boolean isOpaque() {
    return false;
  }

  @Override protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    Graphics2D g2 = (Graphics2D) g.create();
    g2.setRenderingHint(
        RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    g2.setRenderingHint(
        RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
    g2.setFont(new Font(FONT_NAME, Font.PLAIN, FONT_SIZE));
    AffineTransform at = AffineTransform.getScaleInstance(1d, 1.5);
    g2.setFont(g2.getFont().deriveFont(at));

    int cx = getWidth() / 2;
    int cy = getHeight() / 2;
    String curStr = String.format("%02d", currentVal);
    String nxtStr = String.format("%02d", nextVal);
    drawHalf(g2, nxtStr, cx, cy, true, CARD_UPPER_BG);
    drawHalf(g2, curStr, cx, cy, false, CARD_LOWER_BG);
    if (isAnimating) {
      double rad = Math.toRadians(angle);
      double scaleY = Math.abs(Math.cos(rad));
      g2.translate(cx, cy);
      g2.scale(1d, scaleY);
      boolean isTop = angle > 90;
      Color bg = isTop ? CARD_LOWER_BG : CARD_UPPER_BG;
      drawHalf(g2, curStr, 0, 0, isTop, bg);
      int alpha = (int) ((1d - scaleY) * SHADOW_MAX_ALPHA);
      drawShadow(g2, 0, 0, isTop, alpha);
    }
    g2.dispose();
  }

  private void drawHalf(Graphics g, String txt, int cx, int cy, boolean isTop, Color bg) {
    int x = cx - WIDTH / 2;
    int height = HEIGHT / 2 - SLIT_HEIGHT / 2;
    if (isTop) {
      g.setClip(x, cy - HEIGHT / 2, WIDTH, height);
    } else {
      g.setClip(x, cy + SLIT_HEIGHT / 2, WIDTH, height);
    }
    g.setColor(bg);
    g.fillRoundRect(x, cy - HEIGHT / 2, WIDTH, HEIGHT, 18, 18);
    g.setColor(TEXT_COLOR);
    FontMetrics fm = g.getFontMetrics();
    g.drawString(txt, cx - fm.stringWidth(txt) / 2, cy + fm.getAscent() / 2 - 12);
  }

  private void drawShadow(Graphics g, int cx, int cy, boolean isTop, int alpha) {
    g.setColor(new Color(0, 0, 0, Math.min(255, alpha)));
    int h = HEIGHT / 2 - SLIT_HEIGHT / 2;
    if (isTop) {
      g.fillRect(cx - WIDTH / 2, cy - HEIGHT / 2, WIDTH, h);
    } else {
      g.fillRect(cx - WIDTH / 2, cy + SLIT_HEIGHT / 2, WIDTH, h);
    }
  }

  public static JLabel makeColonLabel() {
    JLabel colon = new JLabel(":");
    colon.setFont(colon.getFont().deriveFont(Font.BOLD, FONT_SIZE));
    colon.setForeground(TEXT_COLOR);
    return colon;
  }
}
View in GitHub: Java, Kotlin

Description

  • 数字を配置したパネルの上半分が真上(180∘)から手前に倒れて画面に対して垂直(90∘)になったら、裏側の「次の数字」の下半分が見え始めるアニメーションに切り替えて擬似3D回転を表現する
    • 角度180∘90∘: 「現在の数字の上半分」を、上側の領域で垂直圧縮しながら描画
    • 角度90∘0∘: 「次の数字の下半分」を、下側の領域で垂直伸長(0から元のサイズへ)しながら描画
  • フリップアニメーション用のTimerは時計更新用のTimerとは別に時、分、秒の2桁の数字表示用JPanelごとに用意する
    • JPanelに「新しい数字(たとえば秒)」が設定されたらその「新しい数字」を「次の数字」としてフリップアニメーション用のTimerをスタート
    • 15ミリ秒ごとに15∘ずつ角度を減少しrepaint()で「次の数字」の上半分、「現在の数字」の下半分、フリップ中のパネルを描画
    • 角度が0∘になったらフリップアニメーション用のTimerをストップし、JPanelに表示する「現在の数字」を「次の数字」に変更
  • 時計更新用のTimer100ミリ秒ごとにLocalTime.now(ZoneId.systemDefault())で現在の時間を取得し、時、分、秒用JPanelに設定
LocalTime now = LocalTime.now(ZoneId.systemDefault());
FlipPair hourPair = new FlipPair(now.getHour());
FlipPair minPair = new FlipPair(now.getMinute());
FlipPair secPair = new FlipPair(now.getSecond());
JPanel container = new JPanel(new FlowLayout(FlowLayout.CENTER, 2, 2));
container.setOpaque(false);
container.add(hourPair);
container.add(FlipPair.makeColonLabel());
container.add(minPair);
container.add(FlipPair.makeColonLabel());
container.add(secPair);
add(container);
new Timer(100, e -> {
  LocalTime t = LocalTime.now(ZoneId.systemDefault());
  hourPair.setValue(t.getHour());
  minPair.setValue(t.getMinute());
  secPair.setValue(t.getSecond());
}).start();

Reference

Comment