Android 定制:如何构建一个你想要的 UI 组件

已发表: 2022-03-11

开发人员经常会发现自己需要的 UI 组件不是由他们所针对的平台提供,或者确实提供了,但缺乏特定的属性或行为。 这两种情况的答案都是自定义 UI 组件。

Android UI 模型本质上是可定制的,提供了 Android 定制、测试的方法,以及以各种方式创建定制 UI 组件的能力:

  • 继承现有组件(即TextViewImageView等),并添加/覆盖所需的功能。 例如,继承ImageViewCircleImageView ,重写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:andro 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: 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: 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: ... Same layout as prev button. android:src="@drawable/next_icon"/> </RelativeLayout> <!-- days header --> <LinearLayout android: 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: android:layout_width="match_parent" android:layout_height="340dp" android:numColumns="7"/> </LinearLayout>

2.组件类

以前的布局可以按原样包含在ActivityFragment中,并且可以正常工作。 但是将其封装为独立的 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));

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

安卓定制
标题颜色根据季节变化。

重要说明由于HashSet比较对象的方式,上述检查eventDays.contains(date)中的updateCalendar()不会为日期对象生成 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: 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并赋予它string数据类型。 将其添加到/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: 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 元素仍以正常预期方式运行,并将触发事件以响应用户操作。 那么,我们如何处理这些事件呢?

答案涉及两个部分:

  • 捕获组件内的事件,以及
  • 向组件的父级报告事件(可以是FragmentActivity甚至是另一个组件)。

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

 // 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()提供给日历视图。 这是来自“MainActivity.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 的IntentsBroadcastReceivers 。 当需要通知多个组件有关日历的事件时,这特别有用。 例如,如果在日历中按下一天,则需要在Activity中显示文本,并需要后台Service下载文件。

使用前面的方法将需要Activity向组件提供一个EventHandler ,处理事件,然后将其传递给Service 。 相反,让组件广播一个Intent并且ActivityService都通过自己的BroadcastReceivers接受它不仅让生活更轻松,而且有助于解耦Activity和相关Service

结论

看看 Android 自定义的强大功能!
鸣叫

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

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

在本文中,我们创建了一个日历视图作为示例,主要是因为股票日历视图在很多方面都缺乏。 但是,对于可以创建什么样的组件,您绝不会受到限制。 您可以使用相同的技术来创建您需要的任何东西,天空就是极限!

感谢您阅读本指南,祝您在编码工作中好运!