Pull to refresh

Custom layouts. Part 2. CellLayout

Reading time 10 min
Views 22K
И снова здравствуйте, коллеги.

И снова я пожаловал к вам с топиком кастомной разметки. На этот раз, я задумал сделать некую “ячейчатую” разметку.
Смысл прост: вся площадь контейнера делится на сетку из квадратных ячеек одинакового размера, и каждый потомок может занять произвольные клетки на этой сетке. Используя подобную разметку можно составлять красивую мозаику из кнопок или картинок, не прибегая к многочисленным вложенным layout’ам. И, поскольку, это специально заточенный для этих целей layout, он будет работать очень шустро.



Свойства потомков

Прежде, чем мы приступим к нашей основной задаче — измерению и позиционированию потомков, нам нужно задать, какими свойствами они будут обладать.
Каждый из наших потомков будет обладать следующими свойствами:
  1. left — левая ячейка
  2. top — верхняя ячейка
  3. cellsWidth — количество ячеек по-горизонтали
  4. cellsHeight — количество ячеек по-вертикали


Давайте определим эти свойства в xml:

<resources>
    <declare-styleable name="CellLayout">
        <attr name="columns" format="integer" />
        <attr name="spacing" format="dimension" />
        <attr name="layout_left" format="integer" />
        <attr name="layout_top" format="integer" />
        <attr name="layout_cellsWidth" format="integer" />
        <attr name="layout_cellsHeight" format="integer" />
    </declare-styleable>
</resources>


Также мы определили глобальные свойства нашего layout’a — количество колонок (columns) и отступ внутри ячейки (spacing).

LayoutParams


Ну что, приступим. Создадим наследника ViewGroup, назовем его CellLayout.
Первое, что мы хотим сделать, это свой LayoutParams, который будет содержать определенные ранее аттрибуты, и который будет присваиваться всем потомкам нашего контейнера.
LayoutParams — это специальный контейнер аттрибутов, который передается каждому потомку контейнера. Каждый контейнер может определить свои нестандартные аттрибуты для потомков (например, RelativeLayout вводит очень много layout_* аттрибутов, доступных потомкам, таких, как layout_toLeftOf). Поэтому каждый тип контейнера может расширять базовый набор. Базовый набор реализован в ViewGroup.LayoutParams (layout_width, layout_height). Кроме этого там же есть немного расширенный вариант — MarginLayoutParams, который добавляет отступы (margins).

public static class LayoutParams extends ViewGroup.LayoutParams {

	int top = 0;
	int left = 0;
	int width = 1;
	int height = 1;

	public LayoutParams(Context context, AttributeSet attrs) {
	    super(context, attrs);
	    TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CellLayout);
	    left = a.getInt(R.styleable.CellLayout_layout_left, 0);
	    top = a.getInt(R.styleable.CellLayout_layout_top, 0);
	    height = a.getInt(R.styleable.CellLayout_layout_cellsHeight, -1);
	    width = a.getInt(R.styleable.CellLayout_layout_cellsWidth, -1);

	    a.recycle();
	}

	public LayoutParams(ViewGroup.LayoutParams params) {
	    super(params);

	    if (params instanceof LayoutParams) {
		LayoutParams cellLayoutParams = (LayoutParams) params;
		left = cellLayoutParams.left;
		top = cellLayoutParams.top;
		height = cellLayoutParams.height;
		width = cellLayoutParams.width;
	    }
	}

	public LayoutParams() {
	    this(MATCH_PARENT, MATCH_PARENT);
	}

	public LayoutParams(int width, int height) {
	    super(width, height);
	}

}


Ничего особенного, как видите, тут нет. Подкласc ViewGroup.LayoutParams, хранящий наши свойства и загружающий их из XML.
Теперь, мы хотим попросить наш контейнер передавать экземпляр именно этого класса всем своим потомкам. Для этого нужно переопределить несколько методов ViewGroup:

public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
	return new CellLayout.LayoutParams(getContext(), attrs);
}

protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
	return p instanceof CellLayout.LayoutParams;
}

protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
	return new CellLayout.LayoutParams(p);
}

protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
	return new LayoutParams();
}


В общем, думаю, названия методов говорят сами за себя.

Измерение


Теперь, когда мы определили и реализовали атрибуты потомков, нам необходимо добавить измерение нашего компонента и всех его потомков. Для этого мы переопределим метод onMeasure.
Прежде, чем мы продолжим, я обязан рассказать вам немного о методе onMeasure и его особенностях, чтобы дальше не возникало вопросов. Как нетрудно догадаться, это метод, где мы измеряем наш компонент, а также всех его потомков. Но есть особенности.
Их у него две:
  1. Мы обязаны в нем вызвать метод setMeasuredDimension(width, height);, где width и height — наши размеры
  2. Входные данные ему передаются в достаточно интересном виде.

На вход в onMeasure поступают не ширина и высота, а так называемые спецификации ширины и высоты, будь они неладны. Спецификация тут — это число, в котором закодированы два числа — размер соответствующей стороны, а также его “режим”. Разумеется, она зависит от того, что мы передаем в layout_width и layout_height нашего компонента. Режим — это один из трех вариантов, как мы должны использовать размер, переданный предком при измерении нашего компонента. Их три:
  • EXACTLY — это просто. Это либо четко заданный размер (например, layout_width=”100dp”), или match_parent, тогда нам придет размер соответсвующей стороны предка.
  • AT_MOST — обозначает, что наш компонент может быть не больше заданной величины. Например, если у него layout_width=”wrap_content”, а предок имеет фиксированную ширину, будет AT_MOST <ширина предка>
  • UNSPECIFIED — когда невозможно определить высоту. Используется некоторыми контейнерами вроде ScrollView, и, в, общем-то, позволяет предкам расти в заданном направлении неограниченно.

Стратегия измерения у нас будет следующая:
  • По-горизонтали, в случае, если предок задает размеры как AT_MOST или EXACTLY — мы используем жестко заданный предком размер. Немного более сложно, если предок задает размер UNSPECIFIED. Проблема в том, что мы никак не можем вычислить ширину, не зная размера ячейки, который в свою очередь у нас вычисляется как заданная предком ширина / количество ячеек в строке. Так что, я решил, если у нас нет заданной предком ширины, мы будем использовать 48dp как размер ячейки и вычислять ширину нп основе этого.
  • По-вертикали же, по-скольку наш компонент скорее всего будет использоваться внутри ScrollView и получать UNSPECIFIED, в этом случае, мы будем определять самого нижнего по оси Y потомка и задавать границу как его нижнюю границу. Иначе, мы просто будем расти не больше того, что дает нам предок. Т. к., наш компонент не поддерживает внутреннего скролла, все, что не UNSPECIFIED не имеет большого смысла.


Давайте посмотрим, как я это реализовал:

onLayout
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
	int widthMode = MeasureSpec.getMode(widthMeasureSpec);
	int heightMode = MeasureSpec.getMode(heightMeasureSpec);

	int width = 0;
	int height = 0;

	if (widthMode == MeasureSpec.AT_MOST || widthMode == MeasureSpec.EXACTLY) {
	    width = MeasureSpec.getSize(widthMeasureSpec);
	    cellSize = (float) (getMeasuredWidth() - getPaddingLeft() - getPaddingRight()) / (float) columns;
	} else {
	    cellSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_CELL_SIZE, getResources()
		    .getDisplayMetrics());
	    width = (int) (columns * cellSize);
	}

	int childCount = getChildCount();
	View child;

	int maxRow = 0;

	for (int i = 0; i < childCount; i++) {
	    child = getChildAt(i);

	    LayoutParams layoutParams = (LayoutParams) child.getLayoutParams();

	    int top = layoutParams.top;
	    int w = layoutParams.width;
	    int h = layoutParams.height;

	    int bottom = top + h;

	    int childWidthSpec = MeasureSpec.makeMeasureSpec((int) (w * cellSize) - spacing * 2,
		    MeasureSpec.EXACTLY);
	    int childHeightSpec = MeasureSpec.makeMeasureSpec((int) (h * cellSize) - spacing * 2,
		    MeasureSpec.EXACTLY);
	    child.measure(childWidthSpec, childHeightSpec);

	    if (bottom > maxRow) {
		maxRow = bottom;
	    }
	}

	int measuredHeight = Math.round(maxRow * cellSize) + getPaddingTop() + getPaddingBottom();
	if (heightMode == MeasureSpec.EXACTLY) {
	    height = MeasureSpec.getSize(heightMeasureSpec);
	} else if (heightMode == MeasureSpec.AT_MOST) {
	    int atMostHeight = MeasureSpec.getSize(heightMeasureSpec);
	    height = Math.min(atMostHeight, measuredHeight);
	} else {
	    height = measuredHeight;
	}

	setMeasuredDimension(width, height);
}



готово, наш компонент теперь меняет свою высоту в зависимости от того, что мы в него поместим. Но где же потомки? Их мы добавим на следующем этапе: компоновка.

Компоновка


Следующий шаг — компоновка. Она выполняется в замечательном методе onLayout. Здесь все гораздо проще — мы проходимся по каждому из наших детишек и позиционируем его в нужной ячейке.

protected void onLayout(boolean changed, int l, int t, int r, int b) {
	int childCount = getChildCount();

	View child;
	for (int i = 0; i < childCount; i++) {
	    child = getChildAt(i);

	    LayoutParams layoutParams = (LayoutParams) child.getLayoutParams();

	    int top = (int) (layoutParams.top * cellSize) + getPaddingTop() + spacing;
	    int left = (int) (layoutParams.left * cellSize) + getPaddingLeft() + spacing;
	    int right = (int) ((layoutParams.left + layoutParams.width) * cellSize) + getPaddingLeft() - spacing;
	    int bottom = (int) ((layoutParams.top + layoutParams.height) * cellSize) + getPaddingTop() - spacing;

	    child.layout(left, top, right, bottom);
	}
}


Как видите, все совсем просто.

Результат


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

Развесистый layout
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:cl="http://schemas.android.com/apk/res/com.evilduck.celllayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:ignore="HardcodedText" >

    <com.evilduck.celllayout.CellLayout
        android:id="@+id/cell_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        cl:columns="4"
        cl:spacing="1dp"
        tools:context=".MainActivity" >

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="1"
            cl:layout_left="0"
            cl:layout_top="0"
            android:background="#00FF00"
            android:text="View 1" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="3"
            cl:layout_left="1"
            cl:layout_top="0"
            android:background="#FFFF00"
            android:text="View 2" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="1"
            cl:layout_left="1"
            cl:layout_top="1"
            android:background="#FFFFFF"
            android:text="View 3" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="1"
            cl:layout_left="0"
            cl:layout_top="1"
            android:background="#00FFF0"
            android:text="View 4" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="2"
            cl:layout_left="2"
            cl:layout_top="1"
            android:background="#00FA00"
            android:text="View 5" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="2"
            cl:layout_cellsWidth="3"
            cl:layout_left="0"
            cl:layout_top="2"
            android:background="#AAFFAA"
            android:text="View 5" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="2"
            cl:layout_cellsWidth="1"
            cl:layout_left="3"
            cl:layout_top="2"
            android:background="#45CCdd"
            android:text="View 6" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="3"
            cl:layout_cellsWidth="1"
            cl:layout_left="0"
            cl:layout_top="4"
            android:background="#FF00FF"
            android:text="View 7" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="3"
            cl:layout_cellsWidth="1"
            cl:layout_left="1"
            cl:layout_top="4"
            android:background="#FFFF00"
            android:text="View 8" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="3"
            cl:layout_cellsWidth="1"
            cl:layout_left="2"
            cl:layout_top="4"
            android:background="#00FF00"
            android:text="View 9" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="1"
            cl:layout_left="3"
            cl:layout_top="4"
            android:background="#FFFF00"
            android:text="View 10" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="1"
            cl:layout_left="3"
            cl:layout_top="5"
            android:background="#FFFFFF"
            android:text="View 11" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            cl:layout_cellsHeight="1"
            cl:layout_cellsWidth="1"
            cl:layout_left="3"
            cl:layout_top="6"
            android:background="#555555"
            android:text="View 12" />
    </com.evilduck.celllayout.CellLayout>
</ScrollView>



Результат:



Ура, вроде то, что мы хотели.

Заключение


Как видите, создание своего layout’a — задача не такая уж и сложная, как может показаться. Конечно, я ни в коем случае не призываю вас изобретать свои велосипеды, но, иногда, возникает необходимость, и лучше знать, как это работает, чем не знать, не так ли?
Как обычно, все исходники доступны на моем гитхабе.

Я там также, просто для развлечения, добавил анимированное перемешивание элементов. Настроение было хорошее.

Спасибо за внимание.

EDIT: После публикации статьи со мной связался читатель и рассказал, что андроидовский лаунчер использует аналогичный layout manager, который даже называется так же.
Привожу ссылку, может быть, кому-нибудь пригодится.
https://github.com/chrislacy/LauncherJellyBean/blob/master/src/com/launcherjellybean/android/CellLayout.java.
Tags:
Hubs:
+26
Comments 16
Comments Comments 16

Articles