Пользователь
0,0
рейтинг
29 августа 2013 в 12:22

Разработка → Custom layouts. Part 2. CellLayout

И снова здравствуйте, коллеги.

И снова я пожаловал к вам с топиком кастомной разметки. На этот раз, я задумал сделать некую “ячейчатую” разметку.
Смысл прост: вся площадь контейнера делится на сетку из квадратных ячеек одинакового размера, и каждый потомок может занять произвольные клетки на этой сетке. Используя подобную разметку можно составлять красивую мозаику из кнопок или картинок, не прибегая к многочисленным вложенным 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.
Александр @evilduck
карма
62,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (16)

  • +1
    А так же для подобных целей можно использовать GridLayout, доступный из support library v7.

    Но, конечно, в качестве урока по созданию кастомного лэйаута, ваша статья хороша. Спасибо.
    • +3
      Спасибо за комментарий. Про GridLayout я в курсе, да :) Ну, во-первых, да, это скорее от экспериментаторства. Во-вторых, с GridLayout, есть проблемы. Попробуйте сделать, чтобы он вам, например, 2 кнопки растянул по горизонтали в равной пропорции, я уверен, вас ждет небольшой сюрприз :)
  • 0
    Хорошо рассмотрено создание кастомного контейнера. Для полноты в контрол хорошо бы добавить атрибут управляющий ошибкой округления. Т.е. чтоб была возможность заполнить все выделенное пространство без пропусков (например при ширине в 11 и количестве элементов 3).
    • 0
      Спасибо. Ну, тут можно много свистелок прикрутить, если задаться такой целью. Например, хорошо бы добавить проверку, не выходят ли добавляемые компоненты за границу отведенного количества колонок и все такое. А, с другой стороны, могут быть юз кейсы, когда нужно, чтобы оно немного выплывало :) Все сильно зависит, под какие конкретные цели вы пишите компонент.
  • +1
    Спасибо за толькове описание!
    Я думаю, cellsWidth и cellsHeight лучше заменить на что-нибудь с Columns и Rows потому что упоминание ширины и высоты ячеек сбивает с толку.
  • –2
    Пожалуйста, почитайте code style guidelines.
    • 0
      Во-первых, этот guidelines для сабмита в AOSP, чего я не собираюсь делать. Мой code соответствует Java coding conventions. Но мне бы очень хотелось услышать, что именно, по-вашему, не так в моем коде? Вас смутило отсутствие префикса m?
  • 0
    Круто, как всегда. Спасибо :) Инетересно было бы посмотреть на вашу реализацию AdapterView как в Pinterest и ему подобных. Ну или вообще какие-то танцы вокруг AdapterView. Мне кажется, что это довольно нетривиально и будет интересно.
    • 0
      Спасибо. Если честно, к AdapterView у меня страх :) Особенно не представляю, как сделать аналогичное позиционирование элементов разного размера. Ведь это по-сути задача rectangular bin packing… Но как-нибудь, как появится свободное время, обязательно поиграюсь.
      • 0
        Да, AdapterView заставляет мои коленки трястись :D Мой колега задекомпилил Pinterest и таким образом сделал что-то подобное. Работало не очень, но было похоже :)
        • 0
          А вот здесь Vladimir интересно придумал — просто использует два ListView XD. Наверное, можно использовать больше, если экран становится шире =) Правда, не ясно как оно будет работать при 100+ фоток. Ну и, конечно, иногда получается рассинхрон.
  • +1
    Едиственное, не понял вот этого момента:

    MeasureSpec.makeMeasureSpec(..., LayoutParams.MATCH_PARENT);

    Почему вторым параметром идет LayoutParams.MATCH_PARENT, там же должен лежать mode?
    • +1
      И вот этого:
      if (heightMode == MeasureSpec.EXACTLY) { ... } else if (heightMode == MeasureSpec.EXACTLY) { ...
      • 0
        Спасибо вам! Конечно же это ошибки. В первм случае должно быть MeasureSpec.Exactly, во втором должно было быть AT_MOST
    • 0
      Мда, это ошибка, кончено же там MeasureSpec.EXACTLY. Запутался немного :) Огромное спасибо!
      • 0
        Та пожалуйста) Бывает.

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