Undecorated JFrame recipe

Рубрика: Java | 29 April 2008, 18:34 | juriy

Swing и недекорированные окна.

В этой заметке я хочу рассказать о том, как избавлять фреймы от стандартных декораций Windows – рамок окна, заголовка, кнопок из правого верхнего угла.

Мотивация.
Избавиться от стандартного оформления может захотеться по разным причинам. Чаще всего это причины эстетического характера – хочется иметь полный контроль над внешним видом окна, самостоятельно определить внешний вид рамок и кнопок. Иногда рамка попросту неуместна – например, если вы хотите сделать сплеш-скрин, совмещенный с формой ввода логина.

Решение.

Самый простой способ решить задачу – использовать метод JFrame setUndecorated(true). Вот пример кода, который отображаает фрейм без декораций.

JFrame frame = new JFrame("Fancy Frame");
frame.setSize(500, 300);
frame.setUndecorated(true);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setLocationRelativeTo(null);
frame.setVisible(true);

Этого может вполне хватить, если речь идет о сплеше. Если же речь идет о главном окне приложения, такого решения явно недостаточно – мы лишили пользователя возможности изменять положение и размер окна.

Елси фрейм декорирован, мы можем изменять его размеры, “перетаскивая” за заголовок окна. Если заголовка нет, если два выхода: создать собственный компонент-заголовок и таскать за него или взять существующий компонент и адаптировать его под наши нужды.

Я покажу, как использовать JMenuBar в качестве такого компонента – при нажатии на свободное место на JMenuBar’е пользователь сможет перемещать фрейм.

Класс, который отвечает за перенос фрейма совсем прост:

public class FrameDragger implements MouseListener, MouseMotionListener {

    private JFrame frameToDrag;

    private Point lastDragPosition;

    public FrameDragger(JFrame frameToDrag) {
        this.frameToDrag = frameToDrag;
    }

    public void mouseDragged(MouseEvent e) {
        Point currentDragPosition = e.getLocationOnScreen();
        int deltaX = currentDragPosition.x - lastDragPosition.x;
        int deltaY = currentDragPosition.y - lastDragPosition.y;
        if (deltaX != 0 || deltaY != 0) {
            int x = frameToDrag.getLocation().x + deltaX;
            int y = frameToDrag.getLocation().y + deltaY;
            frameToDrag.setLocation(x, y);
            lastDragPosition = currentDragPosition;
        }
    }

    public void mousePressed(MouseEvent e) {
        lastDragPosition = e.getLocationOnScreen();
    }

    // Другие методы, которые объявлены в интерфейсах
    // оставить пустыми
}

Пока пользователь “тащит” мышь, мы вычисляем, насколько изменились координаты, и смещаем положение фрейма на нужную величину. Теперь осталось добавить JMenuBar:

JFrame frame = new JFrame("Fancy Frame");
JMenuBar menuBar = new JMenuBar();

JMenu menu = new JMenu("File");
menu.add(new JMenuItem("Test"));
menu.add(new JMenuItem("Exit"));
menuBar.add(menu);

FrameDragger frameDragger = new FrameDragger(frame);

menuBar.addMouseListener(frameDragger);
menuBar.addMouseMotionListener(frameDragger);
frame.setJMenuBar(menuBar);

frame.setSize(500, 300);
frame.setUndecorated(true);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setLocationRelativeTo(null);
frame.setVisible(true);

Но и это еще не все, пользователь не может изменять размеры фрейма, ведь рамки нет. Чтобы реализовать изменение размеров мы воспользуемся такой стратегией: напишем компонент, который будет имитировать рамку фрейма.

Нарисовать собственную рамку несложно: достаточно нарисовать несколько вложенных друг в друга прямоугольников разного цвета:

public class ResizerPane extends JComponent implements MouseMotionListener, MouseListener {

    private final static Color OUTTER_BORDER_COLOR = new Color(0x0150A9);
    private final static Color MIDDLE_BORDER_COLOR = new Color(0xA8CEEC);
    private final static Color INNER_BORDER_COLOR = new Color(0x7F8B9F);

    public void paintComponent(Graphics g) {
        Rectangle bounds = new Rectangle(0, 0, getWidth(), getHeight());

        g.setColor(OUTTER_BORDER_COLOR);
        g.drawRect(0, 0, bounds.width - 1, bounds.height - 1);
        g.setColor(MIDDLE_BORDER_COLOR);
        g.drawRect(1, 1, bounds.width - 3, bounds.height - 3);
        g.setColor(INNER_BORDER_COLOR);
        g.drawRect(2, 2, bounds.width - 5, bounds.height - 5);
    }

    // Другие полезные методы
}

Панель должна реагировать только на те события, которые касаются рамки, то есть, области по контуру панели. Остальные события должны “проваливаться” ниже и поступать к другим компонентам. Чтобы получить такую функциональность, прийдется переопределить метод contains. При помощи этого метода Swing определят, принадлежность конкретного пикселя компоненту.

Итак, чтобы отлавливать только нужные события, в методе contains необходимо возвращать true, если указатель мыши находится над рамкой.

Чтобы не дублировать код, который определяет над какой стороной рамки находится мышь, я создал отдельный метод, который занимается этими вычислениями:

   private int getBorderSide(int x, int y) {
        int result = 0;

        if (x <= RESIZE_BORDER_SIZE)
            result |= WEST;
        if (x >= getWidth() - RESIZE_BORDER_SIZE - 1)
            result |= EAST;
        if (y <= RESIZE_BORDER_SIZE)
            result |= NORTH;
        if (y >= getHeight() - RESIZE_BORDER_SIZE - 1)
            result |= SOUTH;

        // Now the corners
        if (x >= getWidth() - RESIZE_CORNER_SIZE) {
            if (y >= getHeight() - RESIZE_CORNER_SIZE)
                result |= (SOUTH | EAST);
            if (y <= RESIZE_CORNER_SIZE)
                result |= (NORTH | EAST);
        }

        if (x <= RESIZE_CORNER_SIZE) {
            if (y >= getHeight() - RESIZE_CORNER_SIZE)
                result |= (SOUTH | WEST);
            if (y <= RESIZE_CORNER_SIZE)
                result |= (NORTH | WEST);
        }

        return result;
    }

Теперь написать код для contains совсем не сложно:

    public boolean contains(int x, int y) {
        int width = getWidth();
        int height = getHeight();

        if (width <= 0 || height <= 0)
            return false;

        return (getBorderSide(x, y) > 0);
    }

Теперь, если зарегистрировать компонент, как MouseListener самого себя, приходить будут только события над рамкой. То есть, в onMouseOver, к примеру, мы можем быть уверены, что курсор над рамкой.

Теперь перейдем к изменению размеров фрейма. Стратегия тут абсолютно аналогична перетаскиванию: вычисляем насколько была сдвинута нажатая мышь и изменяем размер или положение фрейма:

    public void mousePressed(MouseEvent e) {
        lastDragPosition = e.getLocationOnScreen();
        dragDirection = getBorderSide(e.getX(), e.getY());
    }

    public void mouseDragged(MouseEvent e) {
        Point currentDragPosition = e.getLocationOnScreen();
        int deltaX = currentDragPosition.x - lastDragPosition.x;
        int deltaY = currentDragPosition.y - lastDragPosition.y;
        Rectangle currentBounds = frameToResize.getBounds();

        if (deltaX == 0 && deltaY == 0)
            return;

        int x = currentBounds.x;
        int y = currentBounds.y;
        int width = currentBounds.width;
        int height = currentBounds.height;

        if ((dragDirection & WEST) == WEST) {
            x = currentBounds.x + deltaX;
            width = currentBounds.width - deltaX;
        }

        if ((dragDirection & EAST) == EAST) {
            width = currentBounds.width + deltaX;
        }

        if ( (dragDirection & NORTH) == NORTH) {
            y = currentBounds.y + deltaY;
            height = currentBounds.height - deltaY;
        }

        if ( (dragDirection & SOUTH) == SOUTH) {
            height = currentBounds.height + deltaY;
        }

        frameToResize.setBounds(x, y, width, height);
        lastDragPosition = currentDragPosition;
    }

Этого достаточно для того, чтобы изменять размеры фрейма. Не хватает одной маленькой детали: курсор мыши остается стандартным несмотря ни на что. Это тоже исправимо, если переопределить mouseMoved нашего компонента:

    public void mouseMoved(MouseEvent e) {
        int border = getBorderSide(e.getX(), e.getY());
        if (border == (SOUTH|EAST) || border == (NORTH|WEST)) {
            setCursor(Cursor.getPredefinedCursor(Cursor.NW_RESIZE_CURSOR));
            return;
        }

        if (border == (SOUTH|WEST) || border == (NORTH|EAST)) {
            setCursor(Cursor.getPredefinedCursor(Cursor.NE_RESIZE_CURSOR));
            return;
        }

        if (border == EAST || border == WEST) {
            setCursor(Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR));
            return;
        }

        if (border == NORTH || border == SOUTH) {
            setCursor(Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR));
            return;
        }
    }

Вот и все, этого должно вполне хватить для ресайза любого фрейма.

Осталось совсем немного, добавить кнопки минимизации/максимизации/закрытия. Сами кнопки добавлять не сложно, куда интереснее посмотреть, какие действия нужно выполнять при нажатии.

Для кнопки закрытия все просто: хватит банального dispose. С минимизацией, восстановлением состояния, максимизацией немного инетереснее. Для того чтобы менять состояние фрейма можно использовать методы setState и setExtendedState(). К примеру, чтобы минимизировать фрейм, нужно вызвать

setState(JFrame.ICONIFIED);

Для максимизации код немного отличается:

    if (frame.getExtendedState() == MAXIMIZED_BOTH)
        frame.setExtendedState(NORMAL);
    else
        frame.setExtendedState(MAXIMIZED_BOTH);

Казалось бы, это все. Скажу честно, почти все. Как всегда в JRE есть одна маленькая особенность: фреймы без декораций при развертывании накрывают собой Task Bar. Это поведение попросту недопустимо, пользователей нервировать нельзя. То что написано дальше, это скорее хак: потому что “нормальным решением” назвать такой код тяжело. Да и для того чтобы решение реализовать, необходимо иметь доступ к коду фрейма, а это не всегда возможно.

Так вот, чтобы избавиться от баги нужно переопределить setExtendedState, ведь именно он портит нам жизнь:

    public synchronized void setExtendedState(int state) {
        if (maxBounds == null && (state & java.awt.Frame.MAXIMIZED_BOTH) == java.awt.Frame.MAXIMIZED_BOTH) {
            Insets screenInsets = getToolkit().getScreenInsets(getGraphicsConfiguration());
            Rectangle screenSize = getGraphicsConfiguration().getBounds();
            maxBounds = new Rectangle(screenInsets.left, screenInsets.top,
            screenSize.width - screenInsets.right - screenInsets.left,
            screenSize.height - screenInsets.bottom - screenInsets.top);
            super.setMaximizedBounds(maxBounds);
        }
        super.setExtendedState(state);
    }

После этого фрейм заработает, как ожидается.

[UPDATED]
После недолгого тестирования обнаружился небольшой, но довольно неприятный баг, который заключается в следующем. Несмотря на то, что Glass Pane должен лежать _над_ всеми компонентами, PopupMenu отрисовывается поверх панели и оставляет неприятные артефакты на бордере. Решение, подсказанное коллегой, заключается в следующем – отдать фрейму почетное право рисовать свою границу, а Glass Pane сделать полностью прозрачным.

Комментариев: 12

12 Responses to “Undecorated JFrame recipe”

Комментарии:

  1. dimat

    Прикольно!
    Первое что мне захотелось при открытии окна – развернуть его двойным кликом на заголовке, но не получилось. Но это уже мелочь…

  2. Vadim Voituk

    ДимаТ: Меня тоже сразу посетило аналогичное желание.
    Сначала хотел посоветовать Юре добавить эту возможность, но потом подумал что она характерна только для Windows (в других ОС двойной клик не всегда разворачивает окно “на полную”), потому реализация такой “рюшечки” могла затянуть на отдельную заметку.

  3. Farcaller

    ага, в моей любимой макоси по дефолту даблклик кидает окно в док ;)

  4. Chabster

    Еще один способ сделать жава приложение ЕЩЕ уродливее и неудобнее?

    Я, к примеру, часто пользуюсь альт-пробелом. Когда уже до людей дойдет, что MySuperOS-приложение должно выглядеть, как MySuperOS-приложение. И обладать привычными для MySuperOS функциями.

  5. juriy

    Это распространенное мнение, с которым я в корне не согласен. Интерфейс и удобство программы зависят не от платформы, а от радиуса кривизны рук разработчиков и желания посвящать вопросам юзабилити хоть какое-то время.

  6. Chabster

    Я не запускал пример, мне лень. Юра, ответь на два вопроса:
    1) работает ли альт-пробел?
    2) перетаскивается ли приложение на 2-й монитор?

  7. juriy

    1. Да.
    2. Да (по крайней мере, на второй _телевизор_ перетаскивается :-).

    Провел несколько эксеприментов. Под Vista окно ведет себя абсолюто идентично обычному окну.

  8. Vadim Voituk

    Chabster:
    1. А ты в коде где-то видишь обработку Alt-Пробел? (Hint: смотри первых 2 комментария)

    2. Перетаскивается. Только что пробовал.
    По хорошему надо было ещё попробовать развернуть окно на каждом мониторе отдельно.

    А вообще твои комментарии сродни “к чему бы докопаться”.
    Если ты не заметил, статья не затрагивает поведение окна, а акцентирует внимаение на его отрисовке.

    Ты же небось не возмущался когда MS Office 2007 попробовал.
    Даже не просто не возмущался, а ещё и хвалил мол единственный нормальный продукт от MS остался.
    Хороший пример приложения какое положило детородный орган на привычный внешний вид, в угоду отличному юзабилити.

  9. Chabster

    Юра, снимаю шляпу. Но, я бы такого не вытворял.
    Вайт, ворд абсолютно ничего необычного с неклиентской областью не делает. Все выглядит и работает стандартно.

    Меня просто бесят программы, у которых изменено стандартное привычное поведение. И скины тоже ненавижу. Бесполезное тормозящее говно, простите.

    А если завтра в винде добавят поддержку модной фишечки, для которой требуется неклиентская область? Дописывать будете? А 99% пользователей любят Aero. И вот, Васек открывает прогу, а там уродство какое-то вместо стандартных красивостей, которые есть даже у самой затасканной проги, которую написали задолго до Висты.

  10. Chabster

    И по-поводу Риббона – не поленитесь, посмотрите http://msstudios.vo.llnwd.net/o21/mix08/08_WMVs/UX09.wmv

    Вы поймете, что на создание прототипа хорошего интерфейса уходят ГОДЫ. И, юзвери свидетели, попытки девелопера сотворить то, что ему кажется удобным, приводят лишь к матюкам.

  11. Juriy

    >> И, юзвери свидетели, попытки девелопера сотворить то, что ему кажется удобным, приводят лишь к матюкам.

    Согласен не то что на 100, даже на 200%. Созданием интерфейсов должны заниматься дизайнеры, специалисты по юзабилити совместно с пользователями. Задача программиста, в таком случае, сводится к тому чтобы минимальными усилиями реализовать то что разработал дизайнер.

    Именно для этих целей тратится время на изучение фреймворков вроде Swing и API вроде Java2D. Ну и на чтение заметок вроде этой ;-)

  12. Mark

    Здравствуйте, люди добрые.
    А нет ли случаем у вас исходного кода полность всего примера ?
    Если есть, оправьте мне пожалуйсто на мыло mark-avdeev@mail.ru буду очень благодарен.

Leave a Reply