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()函數。

在本文中,我們創建了一個日曆視圖作為示例,主要是因為股票日曆視圖在很多方面都缺乏。 但是,對於可以創建什麼樣的組件,您絕不會受到限制。 您可以使用相同的技術來創建您需要的任何東西,天空就是極限!

感謝您閱讀本指南,祝您在編碼工作中好運!