Jetpack Navigation 實現自定義 View 導航

語言: CN / TW / HK

highlight: androidstudio theme: hydrogen


本文已參與「掘力星計劃」,贏取創作大禮包,挑戰創作激勵金。

前言

Navigation 是 Jetpack 的重要元件之一,用來組織 App 的頁面跳轉。由於官方推薦使用 Framgent 承載頁面的實現,所以一提到 Navigation 首先想到配合 Fragment 使用。其實 Navigation 優秀的設計使其支援任意型別的頁面跳轉,哪怕是一個自定義 View。

本文就介紹一下 Navigation 中 View 的使用。進入正題之前,自回顧一下 Navigation 的基本情況


Navigation 基本構成

Navigation 的使用中涉及以下幾個概念:

  • NavGraph :通過 XML 來設計 APP 各頁面(Destination)之間的跳轉路徑,Android Studio 也中專門提供了編輯器用來編輯 Graph

  • NavHost: NavHost 是一個容器,用來承載 Graph 中的所有節點。Navigation 針對 Fragment 提供了 NavHos t的預設實現 NavHostFragment,可以理解 Graph 中的所有的 Fragment 都是其 ChildFragment 。 本文介紹的自定義 View 的場景中,也需要定義針對自定義 View 的 NavHost

  • NavController: 每個 NavHost 都有一個 Controller,服務於 NavHost 中各節點之間的跳轉和回退

  • Navigator: Controller 通過呼叫 Navigator 實現具體跳轉,Navigator 承擔了跳轉邏輯的實現


Navigation 工作原理

Navigation 中每個頁面都是一個 Destination,可以是 Fragment、Activity 或者 View。每個 Detnation 都有唯一 dest id 進行標識,通過 Action 中查詢 id 可以實現 當前 Destination 往目標 Destination 的跳轉。

類似 MainActivity 一樣,APP 啟動時需要定義一個起始 Destination 作為首頁。

前面介紹過,NavHost 面向不同 Destination 都有具體實現,NavController 也根據 Destination 的型別有不同獲取方式,但都很類似:

  • Fragment.findNavController()
  • View.findNavController()
  • Activity.findNavController(viewId: Int)

獲取 Controller 後,通過其方法 navigate(int)進行跳轉,例如

kotlin findNavController().navigate(R.id.action_first_view_to_second_view) findNavController().navigate(R.id.second_view)


Navigation for View

前面介紹了 Navigation 的基本構成和工作原理,接下來進入正題,實現基於自定義View 的 Navigation。

需要實現以下內容: - ViewNavigator - Attributes for ViewNavigator - ViewDestination - NavigationHostView - Graph file

ViewNavigator

Navigation 提供了自定義 Navigator 的方法:使用 @Navigator.Name 註解。 我們定義一個名字為 screen_view 的 Navigator,在 Graph 的 xml 中可以通過此名字定義對應的NavDestination。

NavDestination 與 Navigator 通過泛型進行約束:Navigator<out NavDestination>

```kotlin @Navigator.Name("screen_view") class ViewNavigator(private val container: ViewGroup) : Navigator() {

private val viewStack: Deque<Pair<Int, Int>> = LinkedList()
private val navigationHost = container as NavigationHostView

override fun navigate(
    destination: ViewDestination,
    args: Bundle?,
    navOptions: NavOptions?,
    navigatorExtras: Extras?
) = destination.apply {
    viewStack.push(Pair(destination.id, destination.layoutId))
    replaceView(navigationHost.getViewForId(destination.layoutId))
}

private fun replaceView(view: View?) {
    view?.let {
        container.removeAllViews()
        container.addView(it)
    }
}

override fun createDestination(): ViewDestination = ViewDestination(this)

override fun popBackStack(): Boolean = when {
    viewStack.isNotEmpty() -> {
        viewStack.pop()
        viewStack.peekLast()?.let {
            replaceView(navigationHost.getViewForId(it.second))
        }
        true
    }
    else -> false
}

fun NavigationHostView.getViewForId(layoutId: Int) = when (layoutId) {
    R.layout.screen_view_first -> FirstView(context)
    R.layout.screen_view_second -> SecondView(context)
    R.layout.screen_view_third -> ThirdView(context)
    R.layout.screen_view_last -> LastView(context)
    else -> null
}

}

```

findNavController().navigate(...) 跳轉畫面,最終會走到 ViewNavigator 的 navigate 方法,此處做兩件事:

  • viewStack 記錄回退棧以便於返回前一畫面
  • replaceView 實現畫面切換

Attributes for ViewNavigator

為 Navigator 定義 Xml 中使用的自定義屬性 layoutId

```xml

<declare-styleable name="ViewNavigator">
    <attr name="layoutId" format="reference" />
</declare-styleable>

```

ViewDestination

@NavDestination.ClassType 允許我們定義自己的 NavDestination ```kotlin @NavDestination.ClassType(ViewGroup::class) class ViewDestination(navigator: Navigator) : NavDestination(navigator) {

@LayoutRes var layoutId: Int = 0

override fun onInflate(context: Context, attrs: AttributeSet) {
    super.onInflate(context, attrs)
    context.resources.obtainAttributes(attrs, R.styleable.ViewNavigator).apply {
        layoutId = getResourceId(R.styleable.ViewNavigator_layoutId, 0)
        recycle()
    }
}

} ```

onInflate 中,接收並解析自定義屬性 layoutId 的值

NavigationHostView

定義 NavHost 的實現 NavigationHostFrame,主要用來建立 Controller,併為其註冊 Navigator 型別、設定 Graph

kotlin class NavigationHostFrame(...) : FrameLayout(...), NavHost { private val navigationController = NavController(context) init { Navigation.setViewNavController(this, navigationController) navigationController.navigatorProvider.addNavigator(ViewNavigator(this)) navigationController.setGraph(R.navigation.navigation) } override fun getNavController() = navigationController }

NavGraph

在 Graph 檔案中,通過 <screen_view/> 定義 NavDestination

```xml

<screen_view
    android:id="@+id/first_screen_view"
    app:layoutId="@layout/screen_view_first"
    tools:layout="@layout/screen_view_first">

    <action
        android:id="@+id/action_first_screen_view_to_second_screen_view"
        app:destination="@id/second_screen_view"
        app:launchSingleTop="true"
        app:popUpTo="@+id/first_screen_view"
        app:popUpToInclusive="false" />

    <action
        android:id="@+id/action_first_screen_view_to_last_screen_view"
        app:destination="@id/last_screen_view"
        app:launchSingleTop="true"
        app:popUpTo="@+id/first_screen_view"
        app:popUpToInclusive="false" />

</screen_view>

<screen_view
    android:id="@+id/second_screen_view"
    app:layoutId="@layout/screen_view_second"
    tools:layout="@layout/screen_view_second">

    <action
        android:id="@+id/action_second_screen_view_to_screen_view_third"
        app:destination="@id/screen_view_third"
        app:launchSingleTop="true"
        app:popUpTo="@+id/main_navigation"
        app:popUpToInclusive="true" />

</screen_view>

<screen_view
    android:id="@+id/last_screen_view"
    app:layoutId="@layout/screen_view_last"
    tools:layout="@layout/screen_view_last" />

<screen_view
    android:id="@+id/screen_view_third"
    app:layoutId="@layout/screen_view_third"
    tools:layout="@layout/screen_view_third" />

```

開啟Android Studio的Navigation編輯器檢視NavGraph:

在這裡插入圖片描述

Setup in Activity

最後,在 Activity 的 layout 中使用此 NavigationHostView 作為容器,並在程式碼中將 NavController 與 NavHost 相關聯

```xml

<com.my.sample.navigation.NavigationHostView
    android:id="@+id/main_navigation_host"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

```

kotlin private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) navController = Navigation.findNavController(mainNavigationHost) Navigation.setViewNavController(mainNavigationHost, navController) }

onBackPressed 中呼叫 NavController 讓各 NavDestination 支援 BackPress

kotlin override fun onSupportNavigateUp(): Boolean = navController.navigateUp() override fun onBackPressed() { if (!navController.popBackStack()) { super.onBackPressed() } }


最後

Navigation 基於 Fragment 提供了開箱即用的實現,同時通過註解預留了可擴充套件介面,便於開發者自定義實現,甚至享受 Android Studio 的編輯器帶來的遍歷。

Fragment 誕生初期由於其功能的不穩定,很多公司會自研一些 Fragment 的替代方案,用作頁面拆分分割,如果你的專案中仍然使用了這些自研框架,那麼也可以考慮通過類似方法為它們適配 Navigation 了 ~

(完)

歡迎在評論區討論,掘金官方將在掘力星計劃活動結束後,在評論區抽送100份掘金周邊,抽獎詳情見活動文章