Android 定制:如何构建一个你想要的 UI 组件
已发表: 2022-03-11开发人员经常会发现自己需要的 UI 组件不是由他们所针对的平台提供,或者确实提供了,但缺乏特定的属性或行为。 这两种情况的答案都是自定义 UI 组件。
Android UI 模型本质上是可定制的,提供了 Android 定制、测试的方法,以及以各种方式创建定制 UI 组件的能力:
继承现有组件(即
TextView
、ImageView
等),并添加/覆盖所需的功能。 例如,继承ImageView
的CircleImageView
,重写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.组件类
以前的布局可以按原样包含在Activity
或Fragment
中,并且可以正常工作。 但是将其封装为独立的 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.需要一些逻辑
为了使这个组件实际上表现得像一个日历视图,一些业务逻辑是有序的。 一开始可能看起来很复杂,但实际上并不多。 让我们分解一下:
日历视图的宽度为 7 天,并且可以保证所有月份都将从第一行的某个位置开始。
首先,我们需要弄清楚月份从哪个位置开始,然后用上个月的数字(30、29、28 等)填充之前的所有位置,直到我们到达位置 0。
然后,我们填写当月的天数(1、2、3……等)。
之后是下个月的日子(同样是 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 元素仍以正常预期方式运行,并将触发事件以响应用户操作。 那么,我们如何处理这些事件呢?
答案涉及两个部分:
- 捕获组件内的事件,以及
- 向组件的父级报告事件(可以是
Fragment
、Activity
甚至是另一个组件)。
第一部分非常简单。 例如,为了处理长按网格项,我们在组件类中分配了一个相应的侦听器:
// 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 的Intents
和BroadcastReceivers
。 当需要通知多个组件有关日历的事件时,这特别有用。 例如,如果在日历中按下一天,则需要在Activity
中显示文本,并需要后台Service
下载文件。
使用前面的方法将需要Activity
向组件提供一个EventHandler
,处理事件,然后将其传递给Service
。 相反,让组件广播一个Intent
并且Activity
和Service
都通过自己的BroadcastReceivers
接受它不仅让生活更轻松,而且有助于解耦Activity
和相关Service
。
结论
因此,这就是您通过几个简单步骤创建自己的自定义组件的方式:
- 创建 XML 布局并设置其样式以满足您的需求。
- 根据您的 XML 布局,从适当的父组件派生您的组件类。
- 添加组件的业务逻辑。
- 使用属性使用户能够修改组件的行为。
- 为了更容易在 UI 设计器中使用该组件,请使用 Android 的
isInEditMode()
函数。
在本文中,我们创建了一个日历视图作为示例,主要是因为股票日历视图在很多方面都缺乏。 但是,对于可以创建什么样的组件,您绝不会受到限制。 您可以使用相同的技术来创建您需要的任何东西,天空就是极限!
感谢您阅读本指南,祝您在编码工作中好运!