本文概述
开发人员发现自己需要的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.需要一些逻辑
为了使该组件实际充当日历视图, 需要执行一些业务逻辑。乍一看似乎很复杂, 但实际上并没有太多。让我们分解一下:
-
日历视图的宽度为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比较对象的方式, 除非日期对象完全相同, 否则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()函数。
在本文中, 我们创建了一个日历视图作为示例, 主要是因为在许多方面缺少股票日历视图。但是, 你绝不限制可以创建哪种组件。你可以使用相同的技术来创建所需的任何东西, 天空是极限!
感谢你阅读本指南, 祝你在编码工作中一切顺利!