iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Mobile Development

如何開發適合電子書閱讀器使用的瀏覽器 Android APP系列 第 23

電子書閱讀器上的瀏覽器 [Day23] 雙視窗可拖拉調整大小元件

  • 分享至 

  • xImage
  •  

在 Day 21 顯示 Google Translate 網頁翻譯內容時,畫面呈現的作法是很單純的新增一個 LinearLayout,把原先的 WebView 和新增的翻譯結果 WebView 各佔一半地放在裡面。這個作法雖然在開發上很快,但是如果想要讓畫面更有彈性的話,就不是那麼合適了。

針對翻譯網頁的顯示,接下來有兩個想要改善的地方:

  1. 兩個網頁畫面的比例可以按照使用者需求調整大小 → 希望有個 drag handler 在畫面中間,讓使用者自行拖拉,改變畫面大小。
  2. 目前預設雙畫面的呈現方式是原 WebView 在左邊,新增的 WebView 在右邊。希望有機會讓使用者切換翻譯結果是在左邊或右邊;甚至,可以把兩個 WebView 切換成上下的關係!

為了要達成上述的功能,如果硬是要在目前的架構中把更多的邏輯塞進去,只會讓程式碼混雜著畫面變化的邏輯和翻譯操作的邏輯。

所以,我另外開發了一個 UI 元件 -- TwoPaneLayout,專門處理這種兩個畫面的 customized container。開發者只要把想要的 UI 元件放進去就好,至於中間的 drag handler,怎麼放怎麼移動;畫面要水平切割或垂直切割,都會由這個 container 內部實作。

這一篇就來介紹一下這個 TwoPaneLayout 的架構和實作方式。

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 (一條線) 都調整成全黑的,讓使用者感覺到拖拉是有作用的。

新增專用的 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:要垂直切割畫面,還是橫向切割。
    這些參數建立好後,在實際使用 TwoPaneLayout 時就可以在 layout xml 中指定想要的初始值。範例如下:

https://ithelp.ithome.com.tw/upload/images/20210923/20140260Mm2wZITHYT.png

上面的 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 分別記入 panel1panel2 參數,便可以根據剛剛讀來的 shouldShowSecondPane 值決定是否顯示。

https://ithelp.ithome.com.tw/upload/images/20210923/201402600TkY66T7Sm.png

如果要顯示的話,panel 1 和 pane2 的大小在一開始會先各分一半畫面的寬度(橫向的情況)。緃向的話,則是各分一半畫面的高度(第163行,省略)。由於畫面的分割位置會隨著拖拉後有所改變,所以 showSubPanel 需要代入目前調整後的位置。
拖拉後調整畫面大小
這部分是整篇文章的精華。在長方塊(drag Handler) 被拖拉時,會收到 Touch 相關 event。針對這些 event 我們要記錄下來相關的變化,然後反應到畫面上。

https://ithelp.ithome.com.tw/upload/images/20210923/20140260KzEkqDl4Hi.png

這邊一樣是以橫向的例子來說明。第 221 行到 228 行會先將分隔線和拖拉長方塊初始化。在橫向時,長方塊要是直的,分隔線也要直的;在緃向時,長方塊要是橫的,分隔線也要是橫的。
第 230 行開始,實作 dragHandlerTouchListener。當收到 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() }
        }
    }

切換兩個畫面的位置

這只要把 panel1panel2 對調就行。

    fun switchPanels() {
        // replace view position
        val tempPanel = panel1
        panel1 = panel2
        panel2 = tempPanel

        updatePanels()
    }

套用到 browser App 中

在 browser 中,預設是不會開啟全文翻譯畫面,而且在拖拉時,不要即時更新畫面。所以在 xml 中是這麼寫的:

https://ithelp.ithome.com.tw/upload/images/20210923/20140260JMrExL0H3e.png

第 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行)。

https://ithelp.ithome.com.tw/upload/images/20210923/20140260BBKYZMIFjY.png

在 TwoPaneLayout 中,這個參數在被賦值時,會同時更新畫面:

    var shouldShowSecondPane = false
        set(value) {
            field = value
            updatePanels()
        }

示範影片

到這裡,關於 TwoPaneLayout 的實作,以及它的應用就都說明完了。下面是它在 browser 中操作的效果。(為了顯示 drag and resize 的功能,我特地編譯了一版是會即時更新的版本)

Yes

參考原始碼版本

https://github.com/plateaukao/browser/releases/tag/v8.11.0


上一篇
電子書閱讀器上的瀏覽器 [Day22] 翻譯功能 (IV) 內容分頁
下一篇
電子書閱讀器上的瀏覽器 [Day24] 翻譯功能 (VI) 翻譯結果與主畫面同步捲動
系列文
如何開發適合電子書閱讀器使用的瀏覽器 Android APP30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言