Weather app: 使用DataBinding,LiveData來製作WeatherDetailFragment

Table of Contents

Table of Contents


上一篇文章已經介紹了網路API處理的部分,接著本篇文章將介紹天氣資訊畫面的設計。 首先,在UI資料夾下面中,先製作了三個畫面用的資料夾「about」,「areaList」,「weatherDetail」。


Github

https://github.com/scobin/Android_WeatherApp/tree/feature/weatherData


build.gradle中啟動DataBinding與加入Material Design工具庫

開啟 app 資料夾下的buid.gradle檔案,加入以下程式碼。

dataBinding

apply plugin: 'kotlin-kapt'
dataBinding {
    enabled = true
}

material design library

implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'com.google.android.material:material:1.0.0'

完成後,檔案內容大概會變成如下。

apply plugin: 'kotlin-kapt'

android {
    ...
    defaultConfig {
        ...
    }
    buildTypes {
        
    }
    // add the following code
    dataBinding {
        enabled = true
    }
}

dependencies {
    ...

    // material design
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'com.google.android.material:material:1.0.0'
}

CollapsingToolbarLayout跟RecyclerView

在WeatherDetailFragment的佈局檔案(weather_detail_fragment.xml)中使用了CollapsingToolbarLayout跟RecyclerView。 大致上的構造如下: CoordinatorLayout

  • AppBarLayout
    • CollapsingToolbarLayout
  • RecyclerView

因為使用DataBinding功能,所以加上<layout>以及<data>。 整個佈局檔案內容如下。

xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="viewModel"
            type="bruntho.com.tennki.ui.weatherDetail.WeatherDetailViewModel" />
    </data>
    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/holo_blue_bright"
        >
        <com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:background="@android:color/transparent"
            android:fitsSystemWindows="true">
            <com.google.android.material.appbar.CollapsingToolbarLayout
                android:id="@+id/collapsingtoolbarlayout"
                android:minHeight="?attr/actionBarSize"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:expandedTitleGravity="top"
                app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

                <TextView
                    android:id="@+id/weather_description"
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize"
                    android:layout_gravity="center"
                    android:gravity="center"
                    android:textSize="36sp"
                    android:textColor="@android:color/background_light"
                    tools:text="17"
                    app:layout_collapseMode="pin"
                    />

                <androidx.appcompat.widget.Toolbar
                    android:id="@+id/toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize"
                    android:background="@android:color/transparent"
                    tools:title="Title"
                    app:title="@{viewModel.cityName}"
                    app:layout_collapseMode="pin">
                </androidx.appcompat.widget.Toolbar>
            </com.google.android.material.appbar.CollapsingToolbarLayout>
        </com.google.android.material.appbar.AppBarLayout>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"
            tools:listitem="@layout/weather_detail_item"
            />
    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</layout>

WeatherRecyclerViewAdapter

設計列表內的項目來呈現所要顯示的資料。 繼承RecyclerView.Adapter,然後實作onCreateViewHolder(),getItemCount(),onBindViewHolder()三個函數。

onCreateViewHolder():設定並選擇項目的設計圖,返回設計樣式。

onBindViewHolder():得到onCreateViewHolder()所返回的樣式,並放上對應的資料。

getItemCount():返回列表的項目個數,這個數字等同於這個列表的最大項目數。

另外,當資料變更時,列表項目也想跟著做更新時,需要透過adapter來執行更新,因此加入以下自定義的函數。 updateData():執行畫面更新時使用。

class WeatherDetailRecyclerAdapter(
    var data: MutableList<X>
): RecyclerView.Adapter<WeatherDetailRecyclerAdapter.WeatherDetailRecyclerHolder>() {
    class WeatherDetailRecyclerHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeatherDetailRecyclerHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.weather_detail_item, parent, false)
        return WeatherDetailRecyclerHolder(view)
    }

    override fun getItemCount(): Int {
        return data.size
    }

    override fun onBindViewHolder(holder: WeatherDetailRecyclerHolder, position: Int) {
        val weather = data[position]
        holder.itemView.temperature.text = weather.main.getTempC().toString() + "℃"
        val date = Date(weather.dt * 1000)
        val localDateTime = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault())
        holder.itemView.time.text = "${localDateTime.hour}時"
        if (localDateTime.hour == 0) {
            holder.itemView.date.text = "${localDateTime.month.value}/${localDateTime.dayOfMonth}"
        } else {
            holder.itemView.date.text = ""
        }
    }

    fun updateData(response: WeatherResponse?) {
        response?.let {
            data = response.list as MutableList<X>
            notifyDataSetChanged()
        }
    }
}

WeatherDetailFragment

Fragment這裡主要執行三件事。

  • DataBinding的設定
  • Adapter的設定
  • LiveData的處理

由於在還沒從網路上取得天氣資料時,列表上是沒有資料顯示在畫面上,而當資料取得成功時,再將資料呈現在畫面的列表中。

這一連串的處理是使用LiveData的功能來完成,因為設定為LiveData的資料可以被監視觀察,當資料有變化時可以執行定義好的處理。

所以在ViewModel裡將weather設定成LiveData然後在Fragment中做監測與資料更新處理。

class WeatherDetailFragment : Fragment() {

    companion object {
        fun newInstance() = WeatherDetailFragment()
    }

    private lateinit var viewModel: WeatherDetailViewModel
    private lateinit var binding: WeatherDetailFragmentBinding

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(inflater, R.layout.weather_detail_fragment, container, false)
        binding.lifecycleOwner = this
        return binding.root
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(this).get(WeatherDetailViewModel::class.java)
        binding.viewModel = viewModel
        val adapter = WeatherDetailRecyclerAdapter(arrayListOf())
        binding.list.apply {
            this.adapter = adapter
        }
        binding.collapsingtoolbarlayout.setCollapsedTitleTextColor(resources.getColor(R.color.white, null))
        binding.collapsingtoolbarlayout.setExpandedTitleColor(resources.getColor(R.color.white, null))
        viewModel.weather.observe(viewLifecycleOwner, Observer {
            adapter.updateData(it)
            if (it.list.isNotEmpty()) {
                binding.weatherDescription.text = "${it.list[0].main.getTempC()}${it.list[0].weather[0].description}"
            }

        })
    }

}

WeatherDetailViewModel

將透過網路請求得到的天氣資料weather設置為LiveData,以方便做畫面更新。

class WeatherDetailViewModel(
    cityId: String? = "7280291",
    val cityName: String? = "Taiwan"
) : ViewModel() {

    private val weatherRepo = WeatherRepo()

    val weather: LiveData<WeatherResponse> get() = _weather
    private var _weather = MutableLiveData<WeatherResponse>()

    init {
        cityId?.let {
            viewModelScope.launch {
                weatherRepo.loadWeather(it)?.let {
                    _weather.value = it
                }
            }
        }

    }
}

溫度的整數值

在WeatherDetailFragment,WeatherDetailRecyclerAdapter中的程式碼可以看到,設定溫度的部分使用了getTempC()這樣的函數,這是因為從API上取得的氣溫含有小數部分,在畫面上我希望只顯示整數部分即可,所以我在Main資料物件(model/Main.kt)這個檔案里加上getTempC()函數。

data class Main(
    val grnd_level: Int,
    val humidity: Int,
    val pressure: Int,
    val sea_level: Double,
    val temp: Double,
    val temp_kf: Double,
    val temp_max: Double,
    val temp_min: Double
) {
    fun getTempC(): Int = temp.toInt()
}

追加補充

若是程式執行後,在以下的部分出現錯誤時,先檢查一下 X物件 的資料的型態定義。

val date = Date(weather.dt * 1000)

如果class X(在model/X.kt中定義的資料物件)的dt被定義為Int的話需要改成Long。 dt是表示TimeStamp的數值,用來計算時間。

data class X(
    val clouds: Clouds,
    val dt: Long,       // <-- Here
    val dt_txt: String,
    val main: Main,
    val snow: Snow,
    val sys: Sys,
    val weather: List<Weather>,
    val wind: Wind
)

以上就是這次WeatherDetailFragment相關的程式碼。 可以修改一下MainActivity的佈局文件讓程式執行時顯示WeatherDetailFragment來看看成果。

activity_main.xml

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/weather_detail_fragment"
        android:name="bruntho.com.tennki.ui.weatherDetail.WeatherDetailFragment"/>

</androidx.constraintlayout.widget.ConstraintLayout>