Создание композитных компонентов на Android

    Приветствую всех Хабра-жителей и Андроид-ценителей!
    Композитный в нашем случае означает «состоящий из нескольких», но вы это и так знаете.
    Итак, есть Задача:
    • Необходимо вывести блок данных, включающий в себя текст, картинки, кнопки и т.д.
      (В нашем случае это будет короткий анонс передачи по ТВ)
    • дизайн блока нарисован специально нанятым дизайнером и вам нельзя отсупать от него ни на пиксель
    • Это блок может иметь какую-то внутреннюю логику работы и компоненты могут влиять друг на друга (у нас «внутренней логикой», будет установка символа "*" в заголовок передачи и смена цвета фона если была нажата кнопка «Буду смотреть»)
    • Таких блоков может быть много и информация для них получается уже в процессе работы приложения
    • как всегда, в процессе работы, дизайн может быть пересмотрен, и вам надо быстро внести изменения в программу не переписывая все с самого начала



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

    или сложный финансовый блок с графиками


    Для начала, рассмотрим альтернативные варианты и их недостатки в применении к нашему случаю.

    Кастом-компоненты (custom component)

    Позволяет менять дизайн и поведение компонента, но только в пределах одного компонента, что нам не подходит по-определению.
    Пример:
    public class CustomImage extends ImageView {
    //...
    public CustomImage(Context context) {
            super(context);
            calcSize();
    }
    
    	void calcSize() {
    	        //предварительные расчёты
    	}
    //...
    }
    


    Динамическое создание UI программными средствами

    С помощью этого способа придется написать километры килобайты кода, в котором вы каждый TextView будете создавать вручную, передавать в него контекст, создавать для него LayoutParams для описания выравнивания, все это помещать в заранее созданные LinearLayout/FrameLayout/RelativeLayout, сотни раз запускать ваш код что бы добиться соответствия дизайну.
    И как только дизайнер пришлёт вам новую версию дизайна, вы, мягко говоря, будете не очень этому рады…
    Абстрактный пример создания нескольких полей в коде:
    public void generateLayout() {
            LinearLayout linearLayout = new LinearLayout(getContext());
            linearLayout.setOrientation(LinearLayout.VERTICAL);
            TextView name = new TextView(getContext());
            name.setText(getContext().getResources().getText(R.string.channel_name).toString());
            name.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18);//18dip
            name.setTypeface(null, Typeface.BOLD);
            name.setPadding(20, 0, 20, 0);
            ViewGroup.MarginLayoutParams layoutParams = new ViewGroup.MarginLayoutParams(
                    ViewGroup.MarginLayoutParams.FILL_PARENT,
                    ViewGroup.MarginLayoutParams.WRAP_CONTENT);
            name.setLayoutParams(layoutParams);
            linearLayout.addView(name);
            for (int i = 0; i < 5; i++) {
                TextView subName = new TextView(getContext());
                subName.setText(getChannelItemName(i));
                subName.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16);
                subName.setTypeface(null, Typeface.NORMAL);
                subName.setPadding(30, 0, 20, 0);
                ViewGroup.MarginLayoutParams subLayoutParams = new ViewGroup.MarginLayoutParams(
                        ViewGroup.MarginLayoutParams.FILL_PARENT,
                        ViewGroup.MarginLayoutParams.WRAP_CONTENT);
                subName.setLayoutParams(subLayoutParams);
                linearLayout.addView(subName);
            }
        }
    

    Табличный Layout

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

    Canvas Draw

    Суть данного метода в простом рисовании на канве, вашего UI компонента.
    Данный метод мало того что обладает недостатками 2-го пункта (сложная ручная подгонка всех элементов UI в соответствии с дизайном), но и имеет еще один существенный недостаток — невозможность использования стандартных элементов управления EditText, Botton, CheckBox, SeekBar, в этом случае их либо придется писать вручную, либо накладывать поверх нашего UI. В любом случае это будет неадекватные затраты времени и сил на решение задачи.
    public class DrawComponent extends View {
        public DrawComponent(Context context) {
            super(context);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            
            //палка, палка, огуречик...
        }
    }
    


    Создание композитного компонента при помощи LayoutInflater

    Наконец мы подошли к самой сути статьи — созданию компонента по приведенному заданию оптимальным способом.
    Для начала мы, как уже привыкли, верстаем наш дизайн layout в XML вручную или при помощи визуального редактора который является частью Eclipse плагина ADT.
    Обязательно всем ключевым элементам UI даем свои уникальные ID.
    Для верстки воспользуемся RelativeLayout, для того что бы иметь возможность задавать относительное положение компонентов внутри родителя и друг относительно друга. Конкретно в этом случае было бы достаточно и вертикального LinearLayout, но мы в образовательных целях лёгких путей не ищем.
    Ширина компонента выставлена жестко(288dip), что бы было как в исходной картинке, но ничего не мешает сделать «fill_parent».

    channel_layout.xml:
    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:id="@+id/program_frame"
            android:layout_width="288dip"
            android:layout_height="wrap_content"
            android:padding="5dip">
        <ImageView
                android:id="@+id/channel_logo"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentTop="true"
                android:layout_alignParentLeft="true"
                android:src="@drawable/russia"/>
        <TextView
                android:id="@+id/program_time"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_below="@+id/channel_logo"
                android:layout_alignLeft="@+id/channel_logo"
                android:layout_marginTop="5dip"
                android:singleLine="true"
                android:textColor="@android:color/black"
                android:textStyle="normal"
                android:textSize="12dp"
                android:text="25.07.2011 15:23"/>
        <TextView
                android:id="@+id/channel_name"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentTop="true"
                android:layout_alignParentRight="true"
                android:textColor="@android:color/black"
                android:textStyle="bold"
                android:textSize="16dp"
                android:singleLine="true"
                android:text="Россия"/>
        <TextView
                android:id="@+id/program_name"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_below="@id/program_time"
                android:layout_centerHorizontal="true"
                android:layout_marginTop="5dip"
                android:textColor="@android:color/black"
                android:textStyle="bold"
                android:textSize="15dp"
                android:singleLine="true"
                android:text="Дымка в Москве"/>
        <TextView
                android:id="@+id/program_description"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_below="@id/program_name"
                android:layout_centerHorizontal="true"
                android:layout_marginTop="5dip"
                android:textColor="@android:color/black"
                android:textStyle="normal"
                android:textSize="12dp"
                android:lines="3"
                android:text="Скандалы, Интриги, Расследования!"/>
        <Button
                android:id="@+id/want_to_watch_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_below="@id/program_description"
                android:layout_centerHorizontal="true"
                android:layout_marginTop="5dip"
                android:paddingLeft="10dip"
                android:paddingRight="10dip"
                android:textColor="@android:color/black"
                android:textStyle="bold"
                android:textSize="15dp"
                android:text="Буду смотреть"/>
    </RelativeLayout>
    

    Для задания свойств текста, можно было бы создать пару стилей, но обойдемся тем что есть, для наглядности. Так же не пинайте за то, что не вынес текстовые надписи в strings.xml, ухудшилась бы читаемость и пришлось бы цитировать еще один файл в статью.
    Далее создаем класс нашего компонента и наследуем его от класса который мы использовали в нашей верстке — RelativeLayout.
    Для того, что бы соединить наш класс и лейаут channel_layout, используем LayoutInflater.
    Так же мы внутри класса определяем переменные для всех полей что бы связать поля класса с UI.
    public class ChannelFrame extends RelativeLayout {
        private TVProgram parentProgram;
        private ImageView channel_logo;
        private TextView channel_name;
        private TextView program_time;
        private TextView program_name;
        private TextView program_description;
        private Button want_to_watch_button;
        private String programName = "";
        private boolean isWannaWatch = false;
    
        public ChannelFrame(Context context) {
            super(context);
            initComponent();
        }
    
        private void initComponent() {
            LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            inflater.inflate(R.layout.channel_layout, this);
            channel_logo = (ImageView) findViewById(R.id.channel_logo);
            channel_name = (TextView) findViewById(R.id.channel_name);
            program_time = (TextView) findViewById(R.id.program_time);
            program_name = (TextView) findViewById(R.id.program_name);
            program_description = (TextView) findViewById(R.id.program_description);
            want_to_watch_button = (Button) findViewById(R.id.want_to_watch_button);
            want_to_watch_button.setOnClickListener(buttonListener);
            updateFields();
        }
        
        private void updateFields() {
            if (isWannaWatch) {
                program_name.setText(programName + "*");
                this.setBackgroundResource(R.drawable.frame_bg_selected);
            } else {
                program_name.setText(programName);
                this.setBackgroundResource(R.drawable.frame_bg);
            }
    
        }
    
        public void setChannelName(String name) {
            channel_name.setText(name);
        }
    
        public void setChannelLogo(int resourceId) {
            channel_logo.setImageResource(resourceId);
        }
    
        public void setChannelLogo(Bitmap image) {
            channel_logo.setImageBitmap(image);
        }
    
        public void setProgramTime(String time) {
            program_time.setText(time);
        }
    
        public void setProgramName(String name) {
            programName = name;
            program_name.setText(programName);
        }
    
        public void setProgramDescription(String name) {
            program_description.setText(name);
        }
    
        private final OnClickListener buttonListener = new OnClickListener() {
            public void onClick(View view) {
                isWannaWatch = !isWannaWatch;
                updateFields();
            }
        };
    
        public TVProgram getParentProgram() {
            return parentProgram;
        }
    
        public void setParentProgram(TVProgram parentProgram) {
            this.parentProgram = parentProgram;
            updateFieldsByParent();
        }
    
        private void updateFieldsByParent() {
            setProgramName(parentProgram.getName());
            setProgramDescription(parentProgram.getDesc());
            setProgramTime(SimpleDateFormat.getInstance().format(parentProgram.getTime()));
            setChannelLogo(parentProgram.getChannelLogo());
            setChannelName(parentProgram.getChannelName());
        }
    }
    

    Теперь в двух словах что я тут наделал: сначала инициализируем все поля и создаем удобные методы для установки значений полей, так например, для установки логотипа есть 2 способа — через указание Id ресурса и через передачу Bitmap.
    Так же наш класс является оберткой над «TVProgram parentProgram» — это еще один способ установки полей нашего UI компонента — вызывая setParentProgram и передавая заполненный объект программы, мы автоматом устанавливаем значения всех UI полей из парента.
    Компонент готов, остается создать его экземпляры, установить значения полей и добавить их на форму:
    public class StartActivity extends Activity {
        private LinearLayout framesContainer;
    
        /**
         * Called when the activity is first created.
         */
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.main);
            framesContainer = (LinearLayout) findViewById(R.id.frames_container);
            for (int i = 0; i < 5; i++) {
                ChannelFrame frame = new ChannelFrame(getApplicationContext());
                frame.setProgramName(".............");
                frame.setProgramDescription(".............");
                frame.setProgramTime(".............");
                framesContainer.addView(frame);
            }
    
        }
    }
    

    И, на последок, скриншот того что у нас получилось:

    И исходники.
    Удачных проектов Вам!
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 10
    • НЛО прилетело и опубликовало эту надпись здесь
      • 0
        Спасибо, сейчас поправлю заголовок
      • 0
        А зачем наследоваться от RelativeLayout? FrameLayout все же попроще и побыстрее. Вам же по сути только один компонент в нем отобразить надо — тот RelativeLayout, который задан в xml.
        • 0
          резонное замечание, но если посмотреть, что же возвращает:
          View view = inflater.inflate(R.layout.channel_layout, null, false);

          то ты увидим что view это RelativeLayout.
          Таким образом мы просто связали RelativeLayout из xml с нашим наследником этого класса.
          Как альтернативный вариант, можно вообще ChannelFrame ни от чего не наследовать, а передать в него объект-контейнер наших фреймов, и уже внутри него добавлять в контейнтейнер полученные view.
        • +2
          Не раскрыт самый интересный аспект — наследование от ViewGroup и самостоятельная реализация onMeasure и onLayout. Там есть некоторые тонкости въехать в которые только по документации не реально. Мне в свое время пришлось читать исходник LinearLayout — очень помог.
          • 0
            Это ни в коем случае не упрек, но можно было бы так же показать как создаются кастомные свойства у самописных компонентов, как они используются в xml ресурсах и как обрабатываются в коде.
            Например, для компонента, который приведен в примере, это могли бы быть и кол-во отображаемых строчек и TextAppearance заголовков и кнопочек и пр.
          • 0
            Задача данного топика не в раскрытии фишек ViewGroup и его наследников.
            В дополненние про интерестные аспекты LayoutInflater можно добавить про метод inflate:
            View inflate(int resource, android.view.ViewGroup root, boolean attachToRoot)
            Вызов LayoutInflater без передачи ему root приводит к тому что inflate игнорирует параметры layout (окружения) из xml файла. Вызов же inflate с root != null и attachToRoot=true, загрузит параметры layout, и приведет к возврату root на выходе, но помешает в дальнейшем менять эти параметры в уже загруженном объекте (только если вы не нашли его при помощи findViewById).
            • 0
              Очень познавательно, спасибо. Пишите еще.
              • 0
                Спасибо за пример.
                Но с точки зрения пользователя эта черная полоса справа будет раздражать, а если экран будет больше, тогда линия контента может стать меньше половины экрана, возможно стоит обратиться к дизайнеру.
                Чем проще, тем лучше, и тут смотрю этот вариант придерживается.
                • 0
                  Как раз возникла необходимость в написании кастомных композитных компонентов.
                  Скажите, пожалуйста, не тормозит ли скроллинг коллекции таких элементов?

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