在 Day 21 顯示 Google Translate 網頁翻譯內容時,畫面呈現的作法是很單純的新增一個 LinearLayout
,把原先的 WebView
和新增的翻譯結果 WebView
各佔一半地放在裡面。這個作法雖然在開發上很快,但是如果想要讓畫面更有彈性的話,就不是那麼合適了。
針對翻譯網頁的顯示,接下來有兩個想要改善的地方:
WebView
在左邊,新增的 WebView
在右邊。希望有機會讓使用者切換翻譯結果是在左邊或右邊;甚至,可以把兩個 WebView
切換成上下的關係!為了要達成上述的功能,如果硬是要在目前的架構中把更多的邏輯塞進去,只會讓程式碼混雜著畫面變化的邏輯和翻譯操作的邏輯。
所以,我另外開發了一個 UI 元件 -- TwoPaneLayout
,專門處理這種兩個畫面的 customized container。開發者只要把想要的 UI 元件放進去就好,至於中間的 drag handler,怎麼放怎麼移動;畫面要水平切割或垂直切割,都會由這個 container 內部實作。
這一篇就來介紹一下這個 TwoPaneLayout
的架構和實作方式。
首先,我建立了一個 TwoPaneLayout
的 class,繼承自 FrameLayout
。
在 Android 中如果要開發一個 class 繼承自 View,以 Kotlin 開發的話可以用下面的寫法,把傳統的三個 constructor 都涵蓋到:
class TwoPaneLayout : FrameLayout {
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : super(context, attrs, defStyleAttr) {
initAttributes(attrs)
initDragHandle()
doOnLayout { initViews() }
...
}
}
initAttributes()
是處理這個 Custom View 特有的屬性。待會兒下面會有更多的說明。
initViews()
被包在 doOnLayout
中的原因是:初始化 View
時會需要知道元件被賦予的寬跟高,所以得先等 onLayout
完成後才拿得到。
畫面中兩個視窗的內容是需要使用者自己設定進來的,所以沒有辦法一開始預先建立。但是為了讓畫面可以調整大小,TwoPaneLayout
中要顯示一個可以拖拉的元件才行;另外,在沒有拖拉時,為了避免畫面兩側的邊界不是很明顯,我還加了一條淺淺的線在中間,讓使用者稍微看得出來兩者間的界線。這兩個元件都是事先產生好的。為此,我建了一個 two_pane_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:id="@+id/separator"
android:layout_width="1px"
android:layout_height="match_parent"
android:background="?attr/colorControlNormal" />
<View
android:id="@+id/floating_line"
android:layout_width="2dp"
android:layout_height="match_parent"
android:visibility="gone"
android:background="?attr/colorControlNormal" />
<View
android:id="@+id/drag_handle"
android:layout_width="12dp"
android:layout_height="50dp"
android:layout_centerVertical="true"
android:layout_marginStart="-6dp"
android:layout_marginTop="-25dp"
android:paddingHorizontal="12dp"
android:visibility="gone"
android:alpha="0.3"
android:background="?attr/colorControlNormal" />
</merge>
id drag_handle 的 View
是一個長長的方塊,平常時它的透明度是 30%。一旦使用者開始拖拉它,我會將它以及 id floating_line 的 View
(一條線) 都調整成全黑的,讓使用者感覺到拖拉是有作用的。
這在網路上找得到的 Custom View 教學中都會看到怎麼新增。一般會是在 values/attr.xml 中加入自定義的 declare-styleable element。以下是我針對 TwoPaneLayout
想要提供的參數加入的內容:
<resources>
<declare-styleable name="TwoPaneLayout">
<attr name="show_second_pane" format="boolean"/>
<attr name="drag_resize" format="boolean"/>
<attr name="orientation">
<enum name="vertical" value="0" />
<enum name="horizontal" value="1" />
</attr>
</declare-styleable>
</resources>
show_second_pane
: 當畫面建立時,是不是直接顯示第二個視窗drag_resize
:當拖拉 drag handler 時,是不是即時更新畫面大小 (在電子紙的情況下,會需要關閉這功能,避免畫面一直閃), 還是等手放開時才更新。orientation
:要垂直切割畫面,還是橫向切割。上面的 layout 會建立一個雙視窗的畫面,預設第二個畫面也會顯示,切割方式是橫向的(會產生左右兩個畫面,左邊是 panel1 ,右邊是 panel2);在拖拉時,畫面大小會即時更新。
讀取 attrs.xml
定義好的參數
剛剛在程式碼 I 中的第 23 行有看到,初始化 TwoPaneLayout
時,會順便把 layout xml 中設定的參數都讀進來。
private fun initAttributes(attrs: AttributeSet?) {
attrs ?: return
val attributeValues = context.obtainStyledAttributes(attrs, R.styleable.TwoPaneLayout)
with(attributeValues) {
try {
shouldShowSecondPane = getBoolean(R.styleable.TwoPaneLayout_show_second_pane, false)
orientation = Orientation.values()[getInt(R.styleable.TwoPaneLayout_orientation, Orientation.Horizontal.ordinal)]
dragResize = getBoolean(R.styleable.TwoPaneLayout_drag_resize, false)
} catch (ex: Exception) {
// TwoPaneLayout configuration error
Log.d("TwoPaneLayout", ex.toString())
} finally {
recycle()
}
}
}
將 xml 中的參數讀出來變成一包資料,再利用 getBoolean(), getInt() 等方式將它們轉成 class 中的變數以供後續初始化的執行。
接下來會稍微說明一下各個功能是如何實作出來的。一樣一樣來看的話,其實都不會太複雜。
為了要做到顯示或隱藏第二個視窗,我們要先找出使用者塞進來的兩個 View
。這件事是實作在 initView()
中:
private fun initViews() {
val userAddedViews = children.iterator().asSequence()
.filter { // line 119
!listOf(separator, floatingLine, dragHandle).contains(it)
}.toList()
if (userAddedViews.size != 2) {
// print errors
}
panel1 = userAddedViews[0]
panel2 = userAddedViews[1]
subPanel = panel2
...
updatePanels()
}
在講解 layout 時有提到,我們會事先建好分隔線(separator),拖拉長方塊(dragHandle),所以只要把事前建好的這些 View 排除掉(第 119 行),剩下的兩個 View 就(應該)是使用者塞進來的兩個元件。如果數量不是 2 的話,那就天下大亂了,因為目前我沒有做任何錯誤處理。
將這兩個 View 分別記入 panel1
和 panel2
參數,便可以根據剛剛讀來的 shouldShowSecondPane
值決定是否顯示。
如果要顯示的話,panel
1 和 pane2
的大小在一開始會先各分一半畫面的寬度(橫向的情況)。緃向的話,則是各分一半畫面的高度(第163行,省略)。由於畫面的分割位置會隨著拖拉後有所改變,所以 showSubPanel
需要代入目前調整後的位置。
拖拉後調整畫面大小
這部分是整篇文章的精華。在長方塊(drag Handler) 被拖拉時,會收到 Touch 相關 event。針對這些 event 我們要記錄下來相關的變化,然後反應到畫面上。
這邊一樣是以橫向的例子來說明。第 221 行到 228 行會先將分隔線和拖拉長方塊初始化。在橫向時,長方塊要是直的,分隔線也要直的;在緃向時,長方塊要是橫的,分隔線也要是橫的。
第 230 行開始,實作 dragHandler
的 TouchListener
。當收到 ACTION_DOWN
時,長方塊要變成全黑的;接著,不斷收到 ACTION_MOVE
時,要調整 drag Handler 的位置和 finalX
的值。如果 dragSize
是設定為 true
的話,便要直接調整畫面大小(第 249 行)。當最後收到 ACTION_UP
,使用者手離開畫面時,再調整一次畫面大小(第 254 行).
這功能的實作很單純,把 orientation
值換掉,再重新初始化就行了。
fun setOrientation(orientation: Orientation) {
if (this.orientation != orientation) {
this.orientation = orientation
initDragHandle()
binding.root.requestLayout()
this.requestLayout()
this.doOnLayout { initViews() }
}
}
這只要把 panel1
和 panel2
對調就行。
fun switchPanels() {
// replace view position
val tempPanel = panel1
panel1 = panel2
panel2 = tempPanel
updatePanels()
}
在 browser 中,預設是不會開啟全文翻譯畫面,而且在拖拉時,不要即時更新畫面。所以在 xml 中是這麼寫的:
第 316 行和 321 行分別是顯示網頁的 WebView
和負責全文翻譯的另一個 WebView
(和翻譯時需要的一些小按鈕)。
另外,跟翻譯相關的邏輯全部都寫在一個 TranslationViewController 中。從它的 constructor
可以看到,我們傳入了 TwoPaneLayout 。
class TranslationViewController(
private val activity: Activity,
private val translationViewBinding: TranslationPanelBinding,
private val twoPaneLayout: TwoPaneLayout,
private val showTranslationAction: () -> Unit,
private val onTranslationClosed: () -> Unit,
private val loadTranslationUrl: (String) -> Unit
) {
在收到要全文翻譯的需求時,TranslationViewController
會去做一大堆事情,然後利用 TwoPaneLayout
顯示負責翻譯的 WebView
(第94行)。
在 TwoPaneLayout 中,這個參數在被賦值時,會同時更新畫面:
var shouldShowSecondPane = false
set(value) {
field = value
updatePanels()
}
到這裡,關於 TwoPaneLayout
的實作,以及它的應用就都說明完了。下面是它在 browser 中操作的效果。(為了顯示 drag and resize 的功能,我特地編譯了一版是會即時更新的版本)
https://github.com/plateaukao/browser/releases/tag/v8.11.0