Summary

JButtonに円形の切り抜きアイコンを設定し、これらの左右がデフォルトでは重なり、マウスオーバー時に水平方向に拡大するようレイアウトされたアバターグループを作成します。

Source Code Examples

class StackedLayout implements LayoutManager {
  private double gapFraction;

  public StackedLayout(double gapFraction) {
    this.gapFraction = gapFraction;
  }

  public void setGapFraction(double gapFraction) {
    this.gapFraction = gapFraction;
  }

  @Override public void layoutContainer(Container parent) {
    int n = parent.getComponentCount();
    if (n > 0) {
      Insets insets = parent.getInsets();
      int x = insets.left;
      int y = insets.top;
      for (int i = 0; i < n; i++) {
        Component c = parent.getComponent(i);
        Dimension d = c.getPreferredSize();
        c.setBounds(x, y, d.width, d.height);
        // Step calc: 60% of width as default overlap, 40% as animated spread
        int step = (int) (d.width * .6 + (d.width * .4 * gapFraction));
        x += step;
      }
    }
  }

  @Override public Dimension preferredLayoutSize(Container parent) {
    Dimension size = new Dimension();
    int n = parent.getComponentCount();
    if (n > 0) {
      int totalWidth = 0;
      int maxHeight = 0;
      for (int i = 0; i < n; i++) {
        Component c = parent.getComponent(i);
        Dimension d = c.getPreferredSize();
        maxHeight = Math.max(maxHeight, d.height);
        if (i < n - 1) {
          // Add overlap for all but the last component
          int step = (int) (d.width * .6 + (d.width * .4 * gapFraction));
          totalWidth += step;
        } else {
          totalWidth += d.width;
        }
      }
      Insets insets = parent.getInsets();
      totalWidth += insets.left + insets.right;
      maxHeight += insets.top + insets.bottom;
      size.setSize(totalWidth, maxHeight);
    }
    return size;
  }

  @Override public Dimension minimumLayoutSize(Container parent) {
    return preferredLayoutSize(parent);
  }

  @Override public void addLayoutComponent(String name, Component comp) {
    // empty
  }

  @Override public void removeLayoutComponent(Component comp) {
    // empty
  }
}
View in GitHub: Java, Kotlin

Description

  • アバターJButtonの重なり状態を調整可能なStackedLayoutを作成
    • StackedLayoutLayoutManager#layoutContainer(...)を実装し、デフォルトの重なり幅は60%、アニメーションによる広がり幅は40%で各アバターJButtonを配置可能
    • 上: 左側のアバターJButtonを手前のレイヤに表示するレイアウト
    • 下: 右側のアバターJButtonを手前のレイヤに表示するレイアウト
  • アバター画像を円形切り抜きしてJButtonに表示
    • アバター画像(このサンプルでは任意のサイズのカラーIcon)を円図形で切り抜き、サイズを一定に揃えてJButtonに配置
    • Graphics#setClip(...)で円形に切り抜くと縁にジャギーが発生するので、JPanelに色相環を描画すると同様のソフトクリッピングで縁をなめらかにしている
    • JButton#getToolTipLocation(...)をオーバーライドして、JTabbedPaneのツールヒントをタブ位置に対応したふきだしに変更すると同様に中央にふきだしのしっぽが存在するJToolTipJButtonの中央上部に表示するよう位置を調整
    • マウスクリックやマウスオーバーで反応する領域をJButton#contains(...)をオーバーライドして円形に制限
class AvatarButton extends JButton {
  private static final int DIAMETER = 24;
  private static final Insets INSETS = new Insets(2, 2, 2, 2);
  private transient JToolTip tip;

  public AvatarButton(Icon icon) {
    super(icon);
  }

  @Override public void updateUI() {
    super.updateUI();
    setContentAreaFilled(false);
    setBorderPainted(false);
    setFocusPainted(false);
  }

  @Override public Dimension getPreferredSize() {
    int w = DIAMETER + INSETS.left + INSETS.right;
    int h = DIAMETER + INSETS.top + INSETS.bottom;
    return new Dimension(w, h);
  }

  @Override protected void paintComponent(Graphics g) {
    Graphics2D g2 = (Graphics2D) g.create();
    g2.setRenderingHint(
        RenderingHints.KEY_ANTIALIASING,
        RenderingHints.VALUE_ANTIALIAS_ON);

    int w = getWidth();
    int h = getHeight();

    // 1. Draw the border with background color
    g2.setColor(getParent().getBackground());
    g2.fill(new Ellipse2D.Double(0, 0, w, h));

    // 2. Render with Soft Clipping
    GraphicsConfiguration gc = g2.getDeviceConfiguration();
    BufferedImage buffer = gc.createCompatibleImage(
        w, h, Transparency.TRANSLUCENT);
    Graphics2D g2d = buffer.createGraphics();
    g2d.setRenderingHint(
        RenderingHints.KEY_ANTIALIASING,
        RenderingHints.VALUE_ANTIALIAS_ON);
    g2d.setRenderingHint(
        RenderingHints.KEY_INTERPOLATION,
        RenderingHints.VALUE_INTERPOLATION_BILINEAR);

    g2d.setComposite(AlphaComposite.Src);
    g2d.fill(new Ellipse2D.Double(INSETS.left, INSETS.top, DIAMETER, DIAMETER));

    // Composite icon inside the circle using SrcAtop
    g2d.setComposite(AlphaComposite.SrcAtop);

    // Scale icon to fit the circle
    Icon icon = getIcon();
    double scale = (double) DIAMETER / Math.max(icon.getIconWidth(), icon.getIconHeight());

    AffineTransform at = AffineTransform.getTranslateInstance(
        INSETS.left, INSETS.top);
    at.scale(scale, scale);
    g2d.transform(at);

    icon.paintIcon(this, g2d, 0, 0);
    g2d.dispose();

    g2.drawImage(buffer, 0, 0, null);
    g2.dispose();
  }

  @Override public boolean contains(int x, int y) {
    int w = getWidth();
    int h = getHeight();
    Ellipse2D circle = new Ellipse2D.Double(0, 0, w, h);
    return circle.contains(x, y);
  }

  @Override public JToolTip createToolTip() {
    if (tip == null) {
      tip = new BalloonToolTip();
      tip.setComponent(this);
    }
    return tip;
  }

  @Override public Point getToolTipLocation(MouseEvent e) {
    return Optional.ofNullable(getToolTipText(e)).map(toolTipText -> {
      JToolTip toolTip = createToolTip();
      toolTip.setTipText(toolTipText);
      Rectangle buttonBounds = SwingUtilities.calculateInnerArea(this, null);
      Dimension tooltipSize = toolTip.getPreferredSize();
      int centerX = (int) (buttonBounds.getCenterX() - tooltipSize.getWidth() / 2d);
      int topY = buttonBounds.y - tooltipSize.height - 2;
      return new Point(centerX, topY);
    }).orElse(null);
  }
}
  • マウスイベント検知用JLayer
    • JButtonやそれらを配置した親JPanelなどにMouseListenerを設定するとマウスの出入りの制御が複雑になるので、JLayerでラップして展開・縮小アニメーションの開始、終了をLayerUI#processMouseEvent(...)で実行している
    • 展開・縮小アニメーションのフレーム更新はLayerUI内に作成したTimerを使用し、StackedLayoutの重なり状態を更新することで実行している
class AvatarLayerUI extends LayerUI<JPanel> {
  private final Timer timer = new Timer(15, null);
  private double currentFraction;
  private double targetFraction;

  @Override public void installUI(JComponent c) {
    super.installUI(c);
    JLayer<?> l = (JLayer<?>) c;
    l.setLayerEventMask(AWTEvent.MOUSE_EVENT_MASK);
    timer.addActionListener(e -> animation((JPanel) l.getView()));
  }

  // Ease-Out: Moves 25% closer to target per frame
  private void animation(JPanel panel) {
    double diff = targetFraction - currentFraction;
    boolean isEnd = Math.abs(diff) < .1;
    if (isEnd) {
      currentFraction = targetFraction;
      timer.stop();
    } else {
      currentFraction += diff * .25;
    }
    StackedLayout layout = (StackedLayout) panel.getLayout();
    layout.setGapFraction(currentFraction);
    panel.revalidate();
    panel.repaint();
  }

  @Override protected void processMouseEvent(MouseEvent e, JLayer<? extends JPanel> l) {
    if (e.getID() == MouseEvent.MOUSE_ENTERED) {
      startAnimation(1d);
    } else if (e.getID() == MouseEvent.MOUSE_EXITED) {
      startAnimation(0d);
    }
  }

  private void startAnimation(double target) {
    this.targetFraction = target;
    if (!timer.isRunning()) {
      timer.start();
    }
  }
}

Reference

Comment