Android自定义:如何构建可以满足你需求的UI组件

本文概述

开发人员发现自己需要的UI组件并不少见, 这不是由他们所针对的平台提供的, 或者确实是由UI提供的, 但是缺少某些属性或行为。两种方案的答案都是自定义UI组件。

Android UI模型具有固有的可自定义性, 它提供了Android自定义, 测试和通过各种方式创建自定义UI组件的功能:

  • 继承现有组件(即TextView, ImageView等), 并添加/覆盖所需的功能。例如, 一个CircleImageView继承ImageView, 重写onDraw()函数以将显示的图像限制为一个圆形, 并添加loadFromFile()函数以从外部存储器加载图像。

  • 从多个组件中创建一个复合组件。这种方法通常利用布局来控制组件在屏幕上的排列方式。例如, 一个LabeledEditText继承了水平方向的LinearLayout, 并且同时包含充当标签的TextView和充当文本输入字段的EditText。

    这种方法也可以利用前一种方法, 即内部组件可以是本地的或自定义的。

  • 最通用, 最复杂的方法是创建一个自绘组件。在这种情况下, 组件将继承通用的View类, 并覆盖诸如onMeasure()以确定其布局, onDraw()来显示其内容等功能。以这种方式创建的组件通常在很大程度上取决于Android的2D绘图API。

Android自定义案例研究:CalendarView

Android提供了本机CalendarView组件。它运行良好, 并提供任何日历组件所期望的最低功能, 显示一个完整的月份并突出显示当前日期。有人可能会说它看起来也不错, 但前提是你要具有本机外观, 并且对自定义外观毫无兴趣。

例如, CalendarView组件无法更改某天的标记方式或使用的背景颜色。例如, 也无法添加任何自定义文本或图形来标记特殊情况。简而言之, 组件看起来像这样, 几乎什么都不能更改:

屏幕截图

AppCompact.Light主题中的CalendarView。

自己做

那么, 如何创建自己的日历视图呢?上面的任何方法都可以。但是, 实用性通常会排除第三个选项(2D图形), 而让我们剩下其他两种方法, 因此在本文中我们将两者结合使用。

接下来, 你可以在此处找到源代码。

1.组件布局

首先, 让我们从组件的外观开始。为简单起见, 让我们在网格中显示日期, 并在顶部显示月份名称以及”下个月”和”上个月”按钮。

屏幕截图

自定义日历视图。

此布局在文件control_calendar.xml中定义, 如下所示。请注意, 某些重复标记已缩写为…:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:orientation="vertical"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="@android:color/white">

   <!-- date toolbar -->
   <RelativeLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:paddingTop="12dp"
      android:paddingBottom="12dp"
      android:paddingLeft="30dp"
      android:paddingRight="30dp">

      <!-- prev button -->
      <ImageView
         android:id="@+id/calendar_prev_button"
         android:layout_width="30dp"
         android:layout_height="30dp"
         android:layout_centerVertical="true"
         android:layout_alignParentLeft="true"
         android:src="@drawable/previous_icon"/>

      <!-- date title -->
      <TextView
         android:id="@+id/calendar_date_display"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_centerVertical="true"
         android:layout_toRightOf="@+id/calendar_prev_button"
         android:layout_toLeftOf="@+id/calendar_next_button"
         android:gravity="center"
         android:textAppearance="@android:style/TextAppearance.Medium"
         android:textColor="#222222"
         android:text="current date"/>

      <!-- next button -->
      <ImageView
         android:id="@+id/calendar_next_button"
         ... Same layout as prev button.
         android:src="@drawable/next_icon"/>
   </RelativeLayout>

   <!-- days header -->
   <LinearLayout
      android:id="@+id/calendar_header"
      android:layout_width="match_parent"
      android:layout_height="40dp"
      android:gravity="center_vertical"
      android:orientation="horizontal">

      <TextView
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_weight="1"
         android:gravity="center_horizontal"
         android:textColor="#222222"
         android:text="SUN"/>

      ... Repeat for MON - SAT.
   </LinearLayout>

   <!-- days view -->
   <GridView
      android:id="@+id/calendar_grid"
      android:layout_width="match_parent"
      android:layout_height="340dp"
      android:numColumns="7"/>
</LinearLayout>

2.组件类

先前的布局可以按原样包含在”活动”或”片段”中, 并且可以正常工作。但是将其封装为独立的UI组件将防止代码重复, 并允许进行模块化设计, 其中每个模块都承担一个责任。

我们的UI组件将是LinearLayout, 以匹配XML布局文件的根。请注意, 代码中仅显示了重要部分。该组件的实现位于CalendarView.java中:

public class CalendarView extends LinearLayout
{
   // internal components
   private LinearLayout header;
   private ImageView btnPrev;
   private ImageView btnNext;
   private TextView txtDate;
   private GridView grid;

   public CalendarView(Context context)
   {
      super(context);
      initControl(context);
   }

   /**
    * Load component XML layout
    */
   private void initControl(Context context)
   {
      LayoutInflater inflater = (LayoutInflater)
         context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

      inflater.inflate(R.layout.control_calendar, this);

      // layout is inflated, assign local variables to components
      header = (LinearLayout)findViewById(R.id.calendar_header);
      btnPrev = (ImageView)findViewById(R.id.calendar_prev_button);
      btnNext = (ImageView)findViewById(R.id.calendar_next_button);
      txtDate = (TextView)findViewById(R.id.calendar_date_display);
      grid = (GridView)findViewById(R.id.calendar_grid);
   }
}

该代码非常简单。创建后, 该组件会扩展XML布局, 并在完成后将内部控件分配给局部变量, 以方便以后访问。

3.需要一些逻辑

为了使该组件实际充当日历视图, 需要执行一些业务逻辑。乍一看似乎很复杂, 但实际上并没有太多。让我们分解一下:

  1. 日历视图的宽度为7天, 可以确保所有月份都从第一行的某个位置开始。

  2. 首先, 我们需要弄清该月的开始位置, 然后使用前一个月的数字(30、29、28等)填充之前的所有位置, 直到到达位置0。

  3. 然后, 我们填写当月的日期(1、2、3…等)。

  4. 在那之后是下个月的日子(再次是1、2、3等), 但是这次我们只填写网格最后一行中的剩余职位。

下图说明了这些步骤:

屏幕截图

自定义日历视图业务逻辑。

网格的宽度已经指定为七个单元格, 表示每周日历, 但是高度如何?网格的最大大小可以通过最坏的情况确定, 即从星期六开始的31天月份的最坏情况, 星期六是第一行中的最后一个单元格, 将需要再显示5行。因此, 将日历设置为显示六行(总共42天)就足以处理所有情况。

但并非所有月份都有31天!通过使用Android的内置日期功能, 我们可以避免由此引起的麻烦, 而无需自己计算天数。

如前所述, Calendar类提供的日期功能使实现非常简单。在我们的组件中, updateCalendar()函数实现了以下逻辑:

private void updateCalendar()
{
   ArrayList<Date> cells = new ArrayList<>();
   Calendar calendar = (Calendar)currentDate.clone();

   // determine the cell for current month's beginning
   calendar.set(Calendar.DAY_OF_MONTH, 1);
   int monthBeginningCell = calendar.get(Calendar.DAY_OF_WEEK) - 1;

   // move calendar backwards to the beginning of the week
   calendar.add(Calendar.DAY_OF_MONTH, -monthBeginningCell);

   // fill cells (42 days calendar as per our business logic)
   while (cells.size() < DAYS_COUNT)
   {
      cells.add(calendar.getTime());
      calendar.add(Calendar.DAY_OF_MONTH, 1);
   }

   // update grid
   ((CalendarAdapter)grid.getAdapter()).updateData(cells);

   // update title
   SimpleDateFormat sdf = new SimpleDateFormat("MMM yyyy");
   txtDate.setText(sdf.format(currentDate.getTime()));
}

4.可自定义

由于负责显示单个日期的组件是GridView, 因此自定义日期显示方式的一个好地方是Adapter, 因为它负责保存数据并为单个网格单元填充视图。

对于此示例, 我们将从CalendearView中要求以下内容:

  • 当天应以蓝色粗体显示。
  • 当前月份以外的天数应设为灰色。
  • 发生事件的日子应显示一个特殊的图标。
  • 日历标题应根据季节(夏季, 秋季, 冬季, 春季)更改颜色。

通过更改文本属性和背景资源, 很容易实现前三个要求。让我们实现一个CalendarAdapter来执行此任务。它可以很简单地成为CalendarView中的成员类。通过重写getView()函数, 我们可以实现以上要求:

@Override
public View getView(int position, View view, ViewGroup parent)
{
   // day in question
   Date date = getItem(position);

   // today
   Date today = new Date();

   // inflate item if it does not exist yet
   if (view == null)
      view = inflater.inflate(R.layout.control_calendar_day, parent, false);

   // if this day has an event, specify event image
   view.setBackgroundResource(eventDays.contains(date)) ?
      R.drawable.reminder : 0);

   // clear styling
   view.setTypeface(null, Typeface.NORMAL);
   view.setTextColor(Color.BLACK);

   if (date.getMonth() != today.getMonth() ||
      date.getYear() != today.getYear())
   {
      // if this day is outside current month, grey it out
      view.setTextColor(getResources().getColor(R.color.greyed_out));
   }
   else if (date.getDate() == today.getDate())
   {
      // if it is today, set it to blue/bold
      view.setTypeface(null, Typeface.BOLD);
      view.setTextColor(getResources().getColor(R.color.today));
   }

   // set text
   view.setText(String.valueOf(date.getDate()));

   return view;
}

最终的设计要求需要更多的工作。首先, 让我们在/res/values/colors.xml中添加四个季节的颜色:

<color name="summer">#44eebd82</color>
<color name="fall">#44d8d27e</color>
<color name="winter">#44a1c1da</color>
<color name="spring">#448da64b</color>

然后, 让我们使用一个数组来定义每个月的季节(为简单起见, 假设北半球;对不起澳大利亚!)。在CalendarView中, 我们添加以下成员变量:

// seasons' rainbow
int[] rainbow = new int[] {
      R.color.summer, R.color.fall, R.color.winter, R.color.spring
};

int[] monthSeason = new int[] {2, 2, 3, 3, 3, 0, 0, 0, 1, 1, 1, 2};

这样, 通过选择适当的季节(monthSeason [currentMonth]), 然后选择相应的颜色(rainbow [monthSeason [currentMonth]), 来选择适当的颜色, 将其添加到updateCalendar()以确保选择了适当的颜色。每当日历更改时。

// set header color according to current season
int month = currentDate.get(Calendar.MONTH);
int season = monthSeason[month];
int color = rainbow[season];

header.setBackgroundColor(getResources().getColor(color));

这样, 我们得到以下结果:

Android自定义

标题颜色根据季节而变化。

重要说明:由于HashSet比较对象的方式, 除非日期对象完全相同, 否则updateCalendar()中的上述check eventDays.contains(date)对于日期对象不会产生true。它不对Date数据类型执行任何特殊检查。要变通解决此问题, 此检查将替换为以下代码:

for (Date eventDate : eventDays)
{
   if (eventDate.getDate() == date.getDate() &&
         eventDate.getMonth() == date.getMonth() &&
         eventDate.getYear() == date.getYear())
   {
      // mark this day for event
      view.setBackgroundResource(R.drawable.reminder);
      break;
   }
}

5.在设计时看起来很丑

Android在设计时选择占位符可能会令人怀疑。幸运的是, Android实际上实例化了我们的组件以便在UI设计器中呈现它, 我们可以通过在组件构造函数中调用updateCalendar()来利用它。这样, 组件将在设计时真正有意义。

屏幕截图

如果初始化组件需要进行大量处理或加载大量数据, 则可能会影响IDE的性能。在这种情况下, Android提供了一个漂亮的函数isInEditMode(), 该函数可用于限制在UI设计器中实际实例化该组件时使用的数据量。例如, 如果有很多事件要加载到CalendarView中, 我们可以在updateCalendar()函数内使用isInEditMode()在设计模式下提供一个空/受限事件列表, 否则加载真实事件。

6.调用组件

组件可以包含在XML布局文件中(用法示例可以在activity_main.xml中找到):

<samples.aalamir.customcalendar.CalendarView
   android:id="@+id/calendar_view"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"/>

并在布局加载后检索与之交互:

HashSet<Date> events = new HashSet<>();
events.add(new Date());

CalendarView cv = ((CalendarView)findViewById(R.id.calendar_view));
cv.updateCalendar(events);

上面的代码创建一个HashSet事件, 将当天添加到该日期, 然后将其传递给CalendarView。结果, CalendarView将以粗体蓝色显示当天, 并在其上放置事件标记:

屏幕截图

CalendarView显示事件

7.添加属性

Android提供的另一种功能是将属性分配给自定义组件。这使Android开发人员可以使用该组件通过布局XML选择设置, 并立即在UI设计器中查看结果, 而不必等待并查看CalendarView在运行时的样子。让我们增加更改组件中日期格式显示的功能, 例如, 拼写月份的全名, 而不是三个字母的缩写。

为此, 需要执行以下步骤:

  • 声明属性。我们将其称为dateFormat并为其指定字符串数据类型。将其添加到/res/values/attrs.xml:
<resources>
   <declare-styleable name="CalendarDateElement">
      <attr name="dateFormat" format="string"/>
   </declare-styleable>
</resources>
  • 在使用组件的布局中使用属性, 并为其赋予值” MMMM yyyy”:
<samples.aalamir.customcalendar.CalendarView
   xmlns:calendarNS="http://schemas.android.com/apk/res/samples.aalamir.customcalendar"
   android:id="@+id/calendar_view"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   calendarNS:dateFormat="MMMM yyyy"/>
  • 最后, 让组件使用属性值:
TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.CalendarView);

dateFormat = ta.getString(R.styleable.CalendarView_dateFormat);

]生成项目, 你会注意到UI设计器中显示的日期更改为使用月份的全名, 例如” 2015年7月”。尝试提供不同的值, 看看会发生什么。

屏幕截图

更改CalendarView属性。

8.与组件交互

你是否尝试过按特定的日期?我们组件中的内部UI元素仍将以其正常的预期方式运行, 并将响应用户操作触发事件。那么, 我们如何处理这些事件呢?

答案包括两个部分:

  • 捕获组件内部的事件, 并
  • 向组件的父级报告事件(可以是片段, 活动或什至是另一个组件)。

第一部分非常简单。例如, 要处理长时间按下的网格项, 我们在组件类中分配一个相应的侦听器:

// long-pressing a day
grid.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener()
{
   
   @Override
   public boolean onItemLongClick(AdapterView<?> view, View cell, int position, long id)
   {
      // handle long-press
      if (eventHandler == null)
      return false;

      Date date = view.getItemAtPosition(position);
      eventHandler.onDayLongPress(date);

      return true;
   }
});

有几种报告事件的方法。一种直接而简单的方法是复制Android的方式:它提供了由组件的父级(上面的代码片段中的eventHandler)实现的组件事件的接口。

可以向接口传递与应用程序相关的任何数据。在我们的例子中, 接口需要公开一个事件处理程序, 该事件处理程序已传递至按下日期的日期。 CalendarView中定义了以下接口:

public interface EventHandler
{
   void onDayLongPress(Date date);
}

可以通过setEventHandler()将父级提供的实现提供给日历视图。这是来自MainMainity.java的示例用法:

@Override
protected void onCreate(Bundle savedInstanceState)
{
   super.onCreate(savedInstanceState);
   setContentView(R.layout.activity_main);

   HashSet<Date> events = new HashSet<>();
   events.add(new Date());

   CalendarView cv = ((CalendarView)findViewById(R.id.calendar_view));
   cv.updateCalendar(events);
   
   // assign event handler
   cv.setEventHandler(new CalendarView.EventHandler()
   {
      @Override
      public void onDayLongPress(Date date)
      {
         // show returned day
         DateFormat df = SimpleDateFormat.getDateInstance();
         Toast.makeText(MainActivity.this, df.format(date), LENGTH_SHORT).show();
      }
   });
}

长按一天将触发一个长按事件, 该事件由GridView捕获并处理, 并通过在提供的实现中调用onDayLongPress()进行报告, 从而在屏幕上显示按下日期的日期:

屏幕截图

解决此问题的另一种更高级的方法是使用Android的Intent和BroadcastReceivers。当需要通知日历事件的多个组件时, 这特别有用。例如, 如果在日历中按一天, 则需要在”活动”中显示文本, 并由后台服务下载文件。

使用以前的方法将要求Activity向组件提供一个EventHandler, 处理该事件, 然后将其传递给Service。相反, 让组件广播一个Intent, 并且Activity和Service都通过它们自己的BroadcastReceivers接受它, 不仅使生活更轻松, 而且还有助于使所讨论的Activity和Service脱钩。

总结

看一下Android定制的强大功能!

鸣叫

因此, 这是你通过几个简单步骤创建自己的自定义组件的方式:

  • 创建XML布局并设置样式以适合你的需求。
  • 根据你的XML布局, 从适当的父组件派生你的组件类。
  • 添加组件的业务逻辑。
  • 使用属性可以使用户修改组件的行为。
  • 为了更轻松地在UI设计器中使用该组件, 请使用Android的isInEditMode()函数。

在本文中, 我们创建了一个日历视图作为示例, 主要是因为在许多方面缺少股票日历视图。但是, 你绝不限制可以创建哪种组件。你可以使用相同的技术来创建所需的任何东西, 天空是极限!

感谢你阅读本指南, 祝你在编码工作中一切顺利!

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?