Skip to main content

08. Разметка

В этой главе мы напишем адаптивную разметку и простейший элемент управления - заголовок.

w

Пример готового проекта лежит здесь. Для выполнения текущего задания Fork от него делать запрещается.

В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

Базовая панель

У используемого нами менеджера окон нет готовых решений для элементов управления и отображения. Вернее, есть заготовки и то далеко не всего.

Элементы управления и отображения - это более высокий уровень абстракции. Его мы напишем сами.

Для начала нам нужно как-то разделять области экрана. Например, в одной у нас будет что-то рисоваться, а во второй будут расположены элементы управления.

w

Внутри панелей можно рисовать свои наборы панелей и т.д.

Сначала добавим константу в класс Application, отвечающую за радиус скругления:

    /**
* радиус скругления элементов
*/
public static final int C_RAD_IN_PX = 4;

Теперь создадим новый пакет panels и добавим в него класс Panel.

w

Этот класс будет родителем для всех остальных. Часть команд для каждого рисования панели повторяется, поэтому я поместил общее для всех рисование в метод paint(), который вызывает метод paintImpl(), условно-уникальный для каждого класса-потомка.

Т.к. всё рисование по умолчанию помещено в методе paint(), то paitImpl() не должен иметь реализации по умолчанию. Значит, мы должны сделать его абстрактным. Из этого следует, что и сам класс мы тоже должны сделать абстрактным.

После пропишем сам класс:

package panels;

import io.github.humbleui.jwm.*;
import io.github.humbleui.skija.Canvas;
import io.github.humbleui.skija.Paint;
import misc.CoordinateSystem2i;

import java.util.function.Consumer;

import static app.Application.C_RAD_IN_PX;


/**
* Класс панели
*/
public abstract class Panel implements Consumer<Event> {
/**
* отступ в пикселях
*/
protected int padding;
/**
* переменная окна
*/
protected final Window window;
/**
* флаг, нужно ли рисовать подложку
*/
private final boolean drawBG;
/**
* цвет подложки
*/
protected final int backgroundColor;

/**
* Конструктор панели
*
* @param window окно
* @param drawBG нужно ли рисовать подложку
* @param backgroundColor цвет фона
* @param padding отступы
*/
public Panel(Window window, boolean drawBG, int backgroundColor, int padding) {
this.window = window;
this.drawBG = drawBG;
this.backgroundColor = backgroundColor;
this.padding = padding;
}

/**
* Функция рисования, вызывающаяся извне
*
* @param canvas область рисования
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем область рисования
canvas.save();
// определяем область рисования
canvas.clipRect(windowCS.getRect());
// рисуем подложку, если выставлен флаг
if (drawBG) {
try (var paint = new Paint()) {
// задаём цвет рисования
paint.setColor(backgroundColor);
// рисуем скруглённый прямоугольник как подложку
canvas.drawRRect(windowCS.getRRect(C_RAD_IN_PX), paint);
}
}
canvas.translate(windowCS.getMin().x, windowCS.getMin().y);
// пользовательская реализация рисования
paintImpl(canvas, windowCS);
// восстанавливаем область рисования
canvas.restore();
}

/**
* Метод рисованияв конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
public abstract void paintImpl(Canvas canvas, CoordinateSystem2i windowCS);

}

В методе paint() мы смещаем область рисования с помощью команды canvas.translate(). Эта команда в качестве аргументов принимает координаты x и y. Система координат смещается на вектор [x;y]-[x;y].

Если говорить по-простому, то область рисования накапливает смещение по осям x и y, а потом при каждом рисовании прибавляет это смещение к каждому обрабатываемому положению.

Создайте новый коммит panel created и отправьте его на сервер.

Заголовки

Чтобы что-то вывести в виде текста, нужно сначала создать объект текстовой линии TextLine. Методу, поставляющему новый экземпляр этой линии передаётся не только строка, содержащая текст, но и шрифт.

Шрифты мы определим в отдельном классе Fonts в пакете app

w

package app;

import io.github.humbleui.skija.Font;
import io.github.humbleui.skija.FontMgr;
import io.github.humbleui.skija.FontStyle;

/**
* Класс шрифтов
*/
public class Fonts {
/**
* 12 шрифт слишком маленький, поэтому приходится определить его немного по-другому
*/
public static final Font FONT12 = new Font(FontMgr.getDefault().matchFamilyStyleCharacter(null, FontStyle.NORMAL, null, "↑".codePointAt(0)), 12);

/**
* 18 шрифт слишком маленький, поэтому приходится определить его немного по-другому
*/
public static final Font FONT18 = new Font(FontMgr.getDefault().matchFamilyStyle(null, FontStyle.NORMAL), 18);
/**
* 24 шрифт слишком маленький, поэтому приходится определить его немного по-другому
*/
public static final Font FONT24 = new Font(FontMgr.getDefault().matchFamilyStyle(null, FontStyle.NORMAL), 24);

/**
* Запрещённый конструктор
*/
private Fonts() {
throw new AssertionError("Этот конструктор нельзя вызывать");
}
}

Создадим теперь первый элемент управления. Этим элементом будет простой текст. В оконных приложениях такие элементы называют заголовками (Label). Label переводится как этикетка.

Цвета текста заголовка и фона панели определим в классе Colors

    ...   
/**
* Цвет текста заголовка
*/
public static final int LABEL_TEXT_COLOR = Misc.getColor(64, 255, 255, 255);
/**
* цвет подложки панелей
*/
public static final int PANEL_BACKGROUND_COLOR = Misc.getColor(32, 0, 0, 0);
...

Создадим для элементов управления отдельный пакет controls, а в нём класс Label.

w

package controls;

import io.github.humbleui.jwm.Event;
import io.github.humbleui.jwm.Window;
import io.github.humbleui.skija.Canvas;
import io.github.humbleui.skija.Paint;
import io.github.humbleui.skija.TextLine;
import misc.CoordinateSystem2i;
import panels.Panel;

import static app.Colors.LABEL_TEXT_COLOR;
import static app.Fonts.FONT12;

/**
* Заголовок
*/
public class Label extends Panel {
/**
* Текст заголовка
*/
public String text;

/**
* Панель на сетке
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param backgroundColor цвет подложки
* @param padding отступы
* @param text текст
*/
public Label(Window window, boolean drawBG, int backgroundColor, int padding, String text) {
super(window, drawBG, backgroundColor, padding);
this.text = text;
}

/**
* Метод рисованияв конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем область рисования
canvas.save();
// создаём линию
try (TextLine line = TextLine.make(text, FONT12)) {
// получаем высоту текста
int capHeight = (int) FONT12.getMetrics().getCapHeight();
// рисуем текст
try (Paint fg = new Paint().setColor(LABEL_TEXT_COLOR)) {
canvas.drawTextLine(line, 0, capHeight, fg);
}
}
// восстанавливаем области рисования
canvas.restore();
}

/**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {

}
}

Т.к. панель расширяет интерфейс Consumer<Event> и при этом не реализует метод accept(Event e), то даже если наш элемент ничего не обрабатывает, нам нужно всё равно реализовать этот метод, если мы хотим создавать объекты класса этого элемента.

Чтобы задать отступ для панелей, добавим константу PANEL_PADDING классу Application

    /**
* отступы панелей
*/
public static final int PANEL_PADDING = 5;

Теперь нам нужно добавить поле заголовка в класс Application

    /**
* Первый заголовок
*/
private final Label label;

Т.к. это - неизменяемое поле, то пока мы не зададим ему какое-нибудь значение в конструкторе, эта строчка будет подчёркиваться красным.

и инициализировать его в конструкторе

    label = new Label(window, true, PANEL_BACKGROUND_COLOR, PANEL_PADDING, "Привет, мир!");
Инициализируйте элементы до создания слоя

Если сначала создать слой, а потом инициализировать переменные, задействованные в рисовании, то при создании слоя будет получена ошибка.

Это происходит из-за того, что при создании слой вызывает метод paint(), вернее, принимает соответствующее сообщение и вызывает его. Но во время этого вызова элемент управления ещё не инициализирован, а значит, равен null.

То же самое происходит, если "повесить" на окно обработчик событий раньше, чем будут инициализированы элементы. Поэтому инициализацию всех элементов в конструкторе нужно выполнять сразу после создания окна.

Теперь метод paint() класса Application выглядит гораздо компактнее

    /**
* Рисование
*
* @param canvas низкоуровневый инструмент рисования примитивов от Skija
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// запоминаем изменения (пока что там просто заливка цветом)
canvas.save();
// очищаем канвас
canvas.clear(APP_BACKGROUND_COLOR);
// рисуем заголовок
label.paint(canvas, windowCS);
// восстанавливаем состояние канваса
canvas.restore();
}

Тест вывелся, но он находится в левом верхнем углу.

w

Так происходит из-за того, что мы передали панели СК всего окна. Создадим теперь новую СК специально для текста и передадим её методу рисования (метод paint() класса Application) заголовка.

    ...
// рисуем заголовок в точке [100,100] с шириной и выостой 200
label.paint(canvas, new CoordinateSystem2i(100, 100, 200, 200));
...

Теперь заголовок сместился, и нам стало видно панель.

w

Создайте новый коммит render text и отправьте его на сервер.

Если при создании панели мы передадим флагу drawBG значение false, то фон рисоваться не будет, и мы увидим только текст (это - демонстрационный код, выполнять не нужно)

    ...
// создаём первый заголовок
label = new Label(window, false, PANEL_BACKGROUND_COLOR, PANEL_PADDING, "Привет, мир!");
...

w

Центрирование текста

Чтобы центрировать текст, добавим два поля классу Label

    /**
* Флаг, нужно ли выравнивать текст по центру по горизонтали
*/
protected boolean centered;
/**
* Флаг, нужно ли выравнивать текст по центру по вертикали
*/
protected boolean vcentered;

И добавим их в аргументы конструктора

    /**
* Панель на сетке
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param backgroundColor цвет подложки
* @param padding отступы
* @param text текст
* @param centered флаг, нужно ли выравнивать текст по центру по горизонтали
* @param vcentered флаг, нужно ли выравнивать текст по центру по вертикали
*/
public Label(Window window, boolean drawBG, int backgroundColor, int padding, String text,
boolean centered, boolean vcentered) {
super(window, drawBG, backgroundColor, padding);
this.text = text;
this.centered = centered;
this.vcentered = vcentered;
}

Теперь метод рисования заголовка Label будет таким:

    /**
* Метод рисованияв конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем область рисования
canvas.save();
// создаём линию
try (TextLine line = TextLine.make(text, FONT12)) {
// получаем высоту текста
int capHeight = (int) FONT12.getMetrics().getCapHeight();
// если нужно центрировать по горизонтали
if (centered)
canvas.translate((windowCS.getSize().x - line.getWidth()) / 2.0f, 0);
if (vcentered)
canvas.translate(0, (windowCS.getSize().y - capHeight) / 2.0f);

// рисуем текст
try (Paint fg = new Paint().setColor(LABEL_TEXT_COLOR)) {
canvas.drawTextLine(line, 0, capHeight, fg);
}
}
// восстанавливаем области рисования
canvas.restore();
}

Заменим теперь создание заголовка в конструкторе класса Application:

    ...
/**
* Конструктор окна приложения
*/
public Application() {
// создаём окно
window = App.makeWindow();

// создаём первый заголовок
label = new Label(window, true, PANEL_BACKGROUND_COLOR, PANEL_PADDING,
"Привет, мир!", true, true);
// задаём обработчиком событий текущий объект
window.setEventListener(this);
...

Теперь текст выровнен

w

Создайте новый коммит text centering works и отправьте его на сервер.

Адаптивная разметка

Если мы изменим размеры окна, то наша панель не переместится и не изменится в размерах.

w

Да, можно было бы впрямую для каждого элемента задавать ОСК через стороны экрана. Но если элементов много, то задавать вручную их параметры становится довольно муторной задачей.

Поэтому напишем класс панели на сетке. Принцип сетки предполагает, что мы разбиваем окно на равные секции. Эти секции в своей сути просто ячейки таблицы. Тогда панель можно определять координатами в этой ячейке, положение на экране и ширину с высотой высчитывать исходя из текущих высоты и ширины окна.

w

Создадим новый класс GridPanel в пакете panels.

package panels;

import io.github.humbleui.jwm.Window;
import io.github.humbleui.skija.Canvas;
import misc.CoordinateSystem2i;

public abstract class GridPanel extends Panel{
/**
* кол-во ячеек сетки по ширине
*/
protected final int gridWidth;
/**
* кол-во ячеек сетки по высоте
*/
protected final int gridHeight;
/**
* x координата в сетке
*/
protected final int gridX;
/**
* y координата в сетке
*/
protected final int gridY;
/**
* кол-во колонок, занимаемых панелью
*/
protected final int colspan;
/**
* кол-во строк, занимаемых панелью
*/
protected final int rowspan;
/**
* Конструктор панели
*
* @param window окно
* @param drawBG нужно ли рисовать подложку
* @param backgroundColor цвет фона
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
*/
public GridPanel(Window window, boolean drawBG, int backgroundColor, int padding, int gridWidth, int gridHeight,
int gridX, int gridY, int colspan, int rowspan) {
super(window, drawBG, backgroundColor, padding);
this.gridWidth = gridWidth;
this.gridHeight = gridHeight;
this.gridX = gridX;
this.gridY = gridY;
this.colspan = colspan;
this.rowspan = rowspan;
}

/**
* Рисование панели
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// рассчитываем размер ячейки таблицы
int cellWidth = (windowCS.getSize().x - (gridWidth + 1) * padding) / gridWidth;
int cellHeight = (windowCS.getSize().y - (gridHeight + 1) * padding) / gridHeight;

// если неправильно рассчитаны
if (cellWidth <= 0 || cellHeight <= 0)
return;

CoordinateSystem2i gridCS = new CoordinateSystem2i(
padding + (cellWidth + padding) * gridX,
padding + (cellHeight + padding) * gridY,
cellWidth * colspan + padding * (colspan - 1),
cellHeight * rowspan + padding * (rowspan - 1)
);

// рисуем ячейку вместо всей панели
super.paint(canvas, gridCS);

}

}

Каждая панель на сетке определена положением gridX, gridY, общим кол-вом ячеек в строке gridWidth и кол-вом ячеек в столбце gridHeight.

Любая ячейка таблицы может занимать несколько клеток. Сколько клеток в строке занимает ячейка, определяет полем colspan, а сколько в столбце - rowspan.

Перепишем теперь класс Label так, чтобы он наследовался от панели на сетке:

/**
* Заголовок
*/
public class Label extends GridPanel {

Теперь нам нужно переписать конструктор

    /**
* Панель на сетке
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param backgroundColor цвет подложки
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
* @param text текст
* @param centered флаг, нужно ли выравнивать текст по центру по горизонтали
* @param vcentered флаг, нужно ли выравнивать текст по центру по вертикали
*/
public Label(Window window, boolean drawBG, int backgroundColor, int padding,
int gridWidth, int gridHeight, int gridX, int gridY, int colspan, int rowspan,
String text, boolean centered, boolean vcentered) {
super(window, drawBG, backgroundColor, padding, gridWidth, gridHeight,
gridX, gridY, colspan, rowspan);
this.text = text;
this.centered = centered;
this.vcentered = vcentered;
}

Также нам нужно изменить создание заголовка в конструкторе класса Application

    ...
/**
* Конструктор окна приложения
*/
public Application() {
// создаём окно
window = App.makeWindow();

// создаём первый заголовок
label = new Label(window, true, PANEL_BACKGROUND_COLOR, PANEL_PADDING,
4, 4, 1, 1, 1, 1, "Привет, мир!", true, true);
// задаём обработчиком событий текущий объект
window.setEventListener(this);
...

Также нам необходимо изменить метод рисования класса Application:

    /**
* Рисование
*
* @param canvas низкоуровневый инструмент рисования примитивов от Skija
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// запоминаем изменения (пока что там просто заливка цветом)
canvas.save();
// очищаем канвас
canvas.clear(APP_BACKGROUND_COLOR);
// рисуем заголовок
label.paint(canvas, windowCS);
// восстанавливаем состояние канваса
canvas.restore();
}

Снова запустим приложение:

w

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

w

Добавим ещё два заголовка в класс Application

    /**
* Первый заголовок
*/
private final Label label2;
/**
* Первый заголовок
*/
private final Label label3;

Инициализируем их в конструкторе:

        // создаём второй заголовок
label2 = new Label(window, true, PANEL_BACKGROUND_COLOR, PANEL_PADDING,
4, 4, 0, 3, 1, 1, "Второй заголовок", true, true);

// создаём третий заголовок
label3 = new Label(window, true, PANEL_BACKGROUND_COLOR, PANEL_PADDING,
4, 4, 2, 0, 1, 1, "Это тоже заголовок", true, true);

Также нужно не забыть запустить их рисование. Для этого в методе paint() допишем соответствующий строки

        ...
// рисуем второй заголовок
label2.paint(canvas, windowCS);
// рисуем третий заголовок
label3.paint(canvas, windowCS);
....

Теперь у нас будет три заголовка

w

Причём для каждого из них работает адаптивная вёрстка

w

Создайте новый коммит adaptive layout и отправьте его на сервер.

Задание

Пример готового проекта лежит здесь. Для выполнения первого задания Fork от него делать запрещается.

В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

Вам необходимо создать свой репозиторий, в котором добавлены следующие коммиты (для каждого сначала указывается название, потом требования):

  1. panel created - добавьте класс панели
  2. render text - создайте элемент заголовка, который позволит вывести текст на панели
  3. text centering works - пропишите центрирование текста заголовка по вертикали и горизонтали
  4. adaptive layout - пропишите адаптивную вёрстку с помощи решётчатой панели

w

Ссылку на github репозиторий необходимо отправить в поле ввода задания на сайте mdl.

Ссылка на контест