Android开发(31)——Food美食项目实战

1.项目预览

2.使用的技术点介绍

3.API接口说明

4.使用Gson自动创建模型

5.使用MVVM模式搭建框架

6.Navigation和ViewBinding

7.添加navhost文件

8.添加BottomNavigationView

9.主界面搭建

10.网络状态

11.详情页界面

12.数据绑定和选项按钮状态

13.viewPager显示详情内容和原料

14.原料item界面搭建和数据绑定

15.Room中收藏表创建

16.收藏页面item布局

一、项目预览
1.主页面下方有一个横向的recyclerView,点开不同的标签会显示不同类型的菜品
副菜

甜品
2.点开一个菜品,就可以显示它的详细界面,包括它的具体做法和使用的原材料。
具体做法

原材料
3.点击右上角的标签,还可以将菜品添加到收藏列表。
收藏列表
二、使用的技术点介绍
1.ROOM Database:下载的数据通过它来缓存,让用户在没有网络的情况下也能查看一些食谱。
2.依赖注入Dagger-Hilt:JetPack里面重要的一个组件。
3.Retorfit:网络通过这个来访问数据。
4.Offline Cache离线缓存:使用第三方库来进行缓存。
5.kotlin Coroutines协程:为了减轻网络下载因线程带来的一些影响。
6.Navigation Component:导航组件。
7.Data StorePreference:用来替代Shared Preference,用它来存储用户的偏好。
8.Data Binding:数据的绑定。
9.ViewModel:使用的设计模式为MVVM设计模式。
10.LiveData:当数据变化,界面也会进行变化。
11.Flow:当数据库里的数据发生变化,界面展示的内容也会发生变化。
12.DiffUtil:对发生变化的进行刷新,比如用户在删掉了收藏夹里面的内容,那么就会自动将其刷新掉。
13.RecyclerView:页面能够一直往下不停地滑动并显示页面,是通过它来实现的。
14.客户端·服务器端数据的交互:获取数据发送HTTP请求,解析HTTP并响应数据。
15.深夜模式:当用户切换到深夜模式时,整个界面会变成深色。
16.MotionLayout,Material组件,Material Design。
17.Shimmer Effect:当我们手指往下滑动刷新时,会有一个特效,就是通过它来实现的。
18.Database inspector:对数据进行增删改等操作。
19.ViewPager2:在主页点进一个菜单,上方会有三个标签栏,它们之间的切换就是通过ViewPager2来实现的。
20.Create Contextual Action Mode:在收藏页面,长按一个收藏的内容,会有编辑信息。
21.和其他应用分享数据:比如说一些社交软件啥的。
22.创建Model Bottom Sheet:这个就是主页右下方那个组件的功能,点击它可以筛选我们需要的菜单食谱,它就是通过Model Bottom Sheet来实现的。这个需要使用网络。
三、API接口说明
1.首先进去该网址https://spoonacular.com/load-api,注册一个账号。登录之后进入MYCONSOLE,点击左侧的profile,就可以查看APIKey。然后再点击DOCS的Full Documentation查看食谱的接口。
记住上面那个网址,这是搜索的API地址
2.这个API地址里面提供了一些参数,方便用户的查询。
一些参数
  • cuisines:哪个国家的菜
  • diet:是哪种类型的菜谱,比如vegan就是素食主义者。
  • introlerances:不能忍受的材料
  • type:搜索的类型,有side dish,bread,aquce,soup,breakfast,beverage等。
  • instructionsRequired:食谱是否要求有说明。true/false
  • addRecipesNutrition:包含食谱的营养信息。true/false
3.将上面的网址与搜索和API接口串起来,可以自己编写一个网址。(类型为汤,素食,包含营养信息)https://api.spoonacular.com/recipes/complexSearch?type=soup&diet=vegan&addRecipeInformation=true&fillIngredients=true&apiKey=1a0edebda73f4a17ad82375357e41313&number=1,打开它之后如果有数据,就证明你编写成功。然后用json解析器把里面的内容解析出来。
解析之后

results展开之后的内容
四、使用Gson自动创建模型
1.Gson插件的安装见上一篇文章。使用之前先导入一下Gson依赖库
 implementation 'com.google.code.gson:gson:2.8.7'
2.我们根据上面的内容,再写出来一个API地址。https://api.spoonacular.com/recipes/complexSearch?type=main%20course&cuisines=Chinese&addRecipeInformation=true&fillIngredients=true&apiKey=1a0edebda73f4a17ad82375357e41313&number=1,再将它的内容复制一下。
3.回到工程里面,new一个kotlin data class file from json,把前面复制的内容copy进去,选择Gson,并命名为FoodRecipe。
自动创建的类
4.根据我们的需要,可以将数据类里面不需要的参数删除。
经过筛选后只剩下三个类
data class ExtendedIngredient(
    @SerializedName("aisle")
    val aisle: String,
    @SerializedName("amount")
    val amount: Double,
    @SerializedName("consistency")
    val consistency: String,
    @SerializedName("id")
    val id: Int,
    @SerializedName("image")
    val image: String,
    @SerializedName("name")
    val name: String,
    @SerializedName("unit")
    val unit: String
)
  • 以上是其中一个数据类的代码,其他两个格式差不多,只是参数不一样罢了。
5.导入以下依赖库。(这是该项目会用到的所有依赖库)
  //gson
    implementation 'com.google.code.gson:gson:2.8.7'

    //retrofit
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

    //coroutine
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")

    // ViewModel
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0-alpha02")
    implementation "androidx.activity:activity-ktx:1.2.0"
    implementation "androidx.fragment:fragment-ktx:1.3.0"

    //viewmodels
    implementation "androidx.activity:activity-ktx:1.2.0"
    implementation "androidx.fragment:fragment-ktx:1.3.0"

    //navigation
    implementation("androidx.navigation:navigation-fragment-ktx:2.3.5")
    implementation("androidx.navigation:navigation-ui-ktx:2.3.5")

    //shimmer
    implementation 'com.facebook.shimmer:shimmer:0.5.0'
    implementation 'com.todkars:shimmer-recyclerview:0.4.1'

    //glide
    implementation 'com.github.bumptech.glide:glide:4.12.0'
    annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'

    //Room
    def room_version = "2.3.0"
    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor "androidx.room:room-compiler:$room_version"
    // optional - Kotlin Extensions and Coroutines support for Room
    implementation("androidx.room:room-ktx:$room_version")
    kapt("androidx.room:room-compiler:$room_version")
    def lifecycle_version = "2.4.0-alpha02"
    // LiveData
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
    // Lifecycles only (without ViewModel or LiveData)
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version")

    //Jsoup
    implementation 'org.jsoup:jsoup:1.13.1'
五、使用MVVM模式搭建框架
1.新建一个名为remote的包,在里面创建一个接口。
interface FoodApi {
    @GET("recipes/complexSearch?addRecipeInformation=true&fillIngredients=true&apiKey=1a0edebda73f4a17ad82375357e41313")
    suspend  fun fetchFoodRecipes(@Query("type")type:String):Response<FoodRecipe>
}
2.在这个包里面新建一个RemoteRepository仓库。
class RemoteRepository {
    //创建FoodApi对象
    private  val foodApi :FoodApi by lazy {
      val retrofit =  Retrofit.Builder()
            .baseUrl("https://api.spoonacular.com/")
            .addConverterFactory((GsonConverterFactory.create()))
            .build()
        retrofit.create(FoodApi::class.java)
    }

    //给外部提供访问接口
    suspend fun fetchFoodRecipes(type:String): Response<FoodRecipe>{
       return foodApi.fetchFoodRecipes(type)
    }
}
3.创建一个名为viewmodel的包,新建一个MainViewModel类。
class MainViewModel(application: Application) :AndroidViewModel(application){
    //网络请求对象
    private val remoteRepository = RemoteRepository()
    //需要给外部观察
    var recipes:MutableLiveData<FoodRecipe> = MutableLiveData()
    //外部通过这个方法发起网络请求
    fun fetchFoodRecipes(type:String) {
        viewModelScope.launch {
           val response =  remoteRepository.fetchFoodRecipes(type)
            if(response.isSuccessful){
                recipes.value = response.body()
            }
        }
    }
}
4.回到MainActivity,创建MainViewModel对象。并在onTouchEvent方法中使用这个对象。
class MainActivity : AppCompatActivity() {
    private val mainViewModel : MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mainViewModel.recipes.observe(this) {
            it.results.forEach { result ->
                Log.v("swl", "${result.title}")
            }
        }
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        if(event?.action == MotionEvent.ACTION_DOWN){
            mainViewModel.fetchFoodRecipes("main course")
        }
        return super.onTouchEvent(event)
    }
}
运行之后就能看到打印结果了。打印出来的都是菜谱的标题。
打印的数据
六、Navigation和ViewBinding
1.navigation组件配置。详情见https://developer.android.google.cn/guide/navigation/navigation-getting-startedhttps://developer.android.google.cn/guide/navigation/navigation-pass-data
  • 添加依赖库。
//Navigation
    implementation("androidx.navigation:navigation-fragment-ktx:2.3.5")
    implementation("androidx.navigation:navigation-ui-ktx:2.3.5")
  • 在build.gradle里面添加一个插件。
id 'androidx.navigation.safeargs.kotlin'
  • 在build.gradel的project里面添加一个classPath
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
2.创建一个名为fragments的包,在里面新建几个fragment,它会自动生成代码和xml文件,然后删掉我们不需要的冗余代码。
  • RecipeFragment
class RecipeFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_recipe, container, false)
    }
}
  • FavoriteFragment
class FavoriteFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_favorite, container, false)
    }
}
  • OtherFragment
class OtherFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_other, container, false)
    }
}
3.使用ViewBinding。可以去官网看一下viewBinding的使用详情。https://developer.android.google.cn/topic/libraries/view-binding
  • 在build.gradle的android{}里面添加以下代码。
buildFeatures{
        viewBinding true
        dataBinding true
    }
4.绑定了之后我们就要使用它,在MainActivity里面修改一下代码。添加一个binding变量,然后在onCreate方法里面给它赋值。
private lateinit var binding :ActivityMainBinding
 override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
      binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
}
其他几个fragment类也要进行如下修改。
class RecipeFragment : Fragment() {
    private lateinit var binding:FragmentRecipeBinding
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentRecipeBinding.inflate(layoutInflater)
        return binding.root
    }
}
七、添加navhost文件
1.在res文件夹里面new一个Android Resource Directory,Resource type选择navigation,然后在这个Directory里面在新建一个navigation resource file。
2.在nav_host.xml中把那三个fragment都添加进来。
my_graph.xml
3.在values里的themes里面把DarkActionBar改为NoActionBar
4.在activity_main.xml中添加一个容器NavHostFragment。并把<fragment>改为
  <androidx.fragment.app.FragmentContainerView
5.在themes.xml里面把Bar 的颜色改为我们的主题颜色。
<item name="android:statusBarColor" tools:targetApi="l">#3E3933</item>
运行结果,最上方的颜色为黑色
八、添加BottomNavigationView
1.添加几张menu图片。在drawable里面new一个Vector Asset,然后点击Clip Art搜索book,就可以得到一个book图标。同理另外三个也是一样。
三个图标
2.新建一个menu的directory,新建一个resource file,在里面新建几个item,把我们之前创建的那些fragment都加进去。id对应的就是Fragment的id
<item
        android:id="@+id/recipeFragment"
        android:title="食谱"
        android:icon="@drawable/ic_book"
        app:showAsAction="ifRoom"
        android:iconTint="#7c7B71"
        tools:targetApi="o" />
    <item
        android:id="@+id/favoriteFragment"
        android:title="收藏"
        android:icon="@drawable/ic_star"
        app:showAsAction="ifRoom"
        android:iconTint="#7c7B71"
        tools:targetApi="o"/>
    <item
        android:id="@+id/otherFragment"
        android:title="其他"
        android:icon="@drawable/ic_other"
        app:showAsAction="ifRoom"
        android:iconTint="#7c7B71"
        tools:targetApi="o"/>
3.在activity_main里面添加一个BottomNavigationView,放在containerNavigation下面。BottomNavigationView里面有一个menu属性,把menu添加进去就行了。
 app:menu="@menu/bottom_menu"
4.改一下控件的颜色。在themes.xml中将将代码按如下所示修改。
<item name="colorPrimary">#F5C713</item>
5.让图标和文字在被选中时为黄色,未被选中时为灰色。
  • 创建一个类型为color的Android Resource Directory,然后在里面添加两个item,包含选中和未选中时的颜色。
    <item android:color="#F5C713" android:state_checked="true"/>
    <item android:color="#7C7B7E" android:state_checked="false"/>
  • 在activity_main.xml的bottomNavgationView里面让图标颜色和文字颜色都采用这个。
        app:itemIconTint="@color/item_color"
        app:itemTextColor="@color/item_color"
运行之后得到以下结果:
运行结果
九、主界面搭建
1.先将我们准备好的几张图片拖动到drawable里面。
2.在fragment_recipe里面我们添加一张背景图片,设置拉伸类型为fitXY。添加一些TextView,再添加一张图片,想要让图片为圆角的话,先添加以下依赖库。
    implementation 'com.google.android.material:material:1.2.0'
3.在values包里面创建一个styles.xml。
 <style name="roundedCornerImageStyle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">25dp</item>
    </style>
4.在fragment_recipes里面添加一个ShapeableImageView。这个就是我们右上角显示的头像。
<com.google.android.material.imageview.ShapeableImageView
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:layout_marginEnd="16dp"
        android:scaleType="centerCrop"
        app:layout_constraintBottom_toBottomOf="@+id/textView2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@+id/textView"
        app:shapeAppearance="@style/roundedCornerImageStyle"
        app:srcCompat="@drawable/head" />
5.然后在它们下方在添加一张一盘菜的长形图片。到现在我们搭建好的页面如下图所示:
目前搭建好的页面
6.添加一个recycleView,它表示用户最近的搜索记录,是一个横向滚动的recycleView。
  • 先在fragment_recipe里面添加一个recycleView。然后新建一个item_type.xml文件,在里面添加一个TextView,使用约束布局,让父容器的宽和高都为wrap_content。
  • 创建一个TypeAdapter类。
class TypeAdapter: RecyclerView.Adapter<TypeAdapter.MyViewHolder>() {
    private val typeList = listOf("主菜","配菜","甜品","开胃菜","沙拉",
    "面包","早餐","汤","饮料","酱","腌制","小吃")

    class MyViewHolder(private val binding:ItemTypeBinding):RecyclerView.ViewHolder(binding.root){
     companion object{
         //创建ViewHolder
         fun from(parent: ViewGroup):MyViewHolder{
           val inflater = LayoutInflater.from(parent.context)
           return  MyViewHolder(ItemTypeBinding.inflate(inflater))
         }
     }

        //绑定数据
        fun bind(type:String){
              binding.titleTextView.text = type
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder.from(parent)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(typeList[position])
    }

    override fun getItemCount(): Int {
          return typeList.size
    }
}
  • 在RecipeFragment创建一个适配器,并写一个方法,在里面配置类型选择的recycleView。在onCreateView()里面调用该方法。
private fun initRecycleView(){
        //配置类型选择的recycleView
        binding.typeRecycleView.layoutManager = LinearLayoutManager(
                requireContext(),RecyclerView.HORIZONTAL,false)
        binding.typeRecycleView.adapter = typeAdapter
    }
  • 运行之后得到下面的结果:
中间的TextView是可以横向拉动的
7.中间很多种菜的类型,那么会有一个当前的默认选中的类型,我们要将那个类型的颜色标亮一点。
  • 在clolor包下面创建一个type_item_selector.xml文件,代码如下图所示:
    <item android:color="#F5C713" android:state_selected="true"/>
    <item android:color="#7C7B7E" android:state_selected="false"/>
  • 在item_type.xml中改一下字体颜色。
android:textColor="@color/type_item_selector"
  • TypeAdapter类的绑定数据bind()反方里面,监听一下被绑定的对象,如果被绑定,就将selected设为true
 fun bind(type:String){
              binding.titleTextView.text = type
              binding.titleTextView.setOnClickListener {
                  it.isSelected = true
              }
        }
  • 运行之后,随便点击一个菜品类型,它就会变亮。
点亮之后
但是又有bug,因为当我们进入页面之后,应该默认第一个是被点亮的,而且一次只能点亮一个。
8.先点亮第一个。写一个方法,修改文本的默认状态。
fun changeSelectedStatus(status:Boolean){
            binding.titleTextView.isSelected = status
        }
  • onBindViewHolder()方法里面判断一下position的位置,如果是0,那么就将其标亮。
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(typeList[position])
        if(position==0){
            holder.changeSelectedStatus(true)
        }
    }
9.当我们点击别的类型时,下面的内容会更新,新的类型会被标亮,原来的类型又会变暗。
  • TypeAdapter里面定义变量来记录当前被选中的那一个和事件回调结果。
  private var lastSelectedPosition = 0
    //事件回调的lambda
    var callBack:((current:Int,last:Int)->Unit)?=null
  • MyViewHolder类里面也定义一个callBack
//数据回调
        var callBack:((Int)->Unit)? = null
  • bind()方法里面使用回调。
fun bind(type:String,position: Int){
              binding.titleTextView.text = type
              binding.titleTextView.setOnClickListener {
                  callBack?.let { it(position) }
              }
        }
  • onCreateViewHolder()方法里面处理回调事件。
   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val holder =  MyViewHolder.from(parent)
        //处理点击之后的回调事件
        holder.callBack={
            //点的是不是同一个
            if(it!=lastSelectedPosition){
                callBack?.let {call->
                    call(it,lastSelectedPosition)
                    //记录当前被选中滚动索引
                    lastSelectedPosition = it
                }
            }
        }
        return holder
    }
  • onBindViewHolder()方法里面修改选中状态。
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(typeList[position],position)
        if(position==lastSelectedPosition){
            holder.changeSelectedStatus(true)
        }else{
            holder.changeSelectedStatus(false)
        }
    }
  • RecipeFragment类里面的initRecycleView()方法中处理回调事件。
 private fun initRecycleView() {
        //配置类型选择的recycleView
        binding.typeRecycleView.layoutManager = LinearLayoutManager(
                requireContext(), RecyclerView.HORIZONTAL, false)
        binding.typeRecycleView.adapter = typeAdapter
        //处理回调事件
        typeAdapter.callBack={current, last ->  
            val currentHolder = binding.typeRecycleView
                    .findViewHolderForAdapterPosition(current) as TypeAdapter.MyViewHolder
            val lastHolder = binding.typeRecycleView
                    .findViewHolderForAdapterPosition(last)
            //选中当前类型
            currentHolder.changeSelectedStatus(true)
            if(lastHolder!=null){
               val lastTypeHolder = lastHolder as TypeAdapter.MyViewHolder
                //取消选中之前的类型
                lastTypeHolder.changeSelectedStatus(false)
            }else{
                //重新把上一次选中的item刷新
                typeAdapter.notifyItemChanged(last)
            }
        }
    }
最后结果如下图所示,只能选中一个类型。
运行结果
10.当我们点击一个类型时,就会去网上下载数据。先把前面MainActivity里面创建的mainViewModel给删了。因为现在数据和我们选择的食谱类型有关,不用在MainActivity里面显示数据了。具体的执行任务应该在RecipeFragment里面执行。
  • 在RecipeFragment里面创建一个MainViewModel对象
private val mainViewModel:MainViewModel by viewModels()
  • 写一个方法来获取选择的类型。
 private fun fetchData(type:String){
        mainViewModel.fetchFoodRecipes(type)
    }
  • onCreateView()方法里面使用ViewModel显示数据。默认显示的是主菜。
mainViewModel.recipes.observe(viewLifecycleOwner){
            //显示数据
            it.results.forEach {result->
                Log.v("swl",result.title)}
        }
        fetchData("主菜")
  • initRecycleView()里面调用上面的方法获取数据。
//获取数据
            fetchData(typeAdapter.typeList[current])
  • 打印结果如下图所示:(因为用的是外国人的数据,所以打印出来的菜名都是英文)
打印结果
11.接下来我们开始搭建下面的内容。
  • 先添加shimmerRecycleView的依赖库。
    implementation 'com.facebook.shimmer:shimmer:0.5.0'
    implementation 'com.todkars:shimmer-recyclerview:0.4.1'
  • 在fragment_recipes.xml中添加一个shimmerRecycleView,调整一下布局。并设置数量为4
app:shimmer_recycler_item_count="4"
  • 创建一个layout资源文件food_item_shimmer_layout,作为下方显示菜品的模板。先添加一个view,然后在drawable里面新建一个资源文件,作为该view的背景。round_corner_shape.xml添加的内容如下图所示:
    <corners android:radius="28dp"/>
    <solid android:color="#333233"/>
  • 让后将view的background设为该资源文件。把view的高度和宽度写死,分别为193dp和159dp。
  • 在drawable里面新建一个资源文件circle_shape.xml,其代码如下所示:
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="60dp"/>
    <solid android:color="#7C7B7E"/>
</shape>
  • 添加一个view,将它的background设为上面那个circle_shape.xml。它的宽度和高度都设置为120dp。然后再添加几个view,最后的布局效果如下图所示:
布局效果
12.在fragment_recipes.xml中的shimmerRecycleView中将上面搭建的xml作为它的布局。
 app:shimmer_recycler_layout="@layout/food_item_shimmer_layout"
13.在RecipeFragment里面写一个initFoodRecycleView方法。然后在onCreateView方法里面调用该方法。
 private fun initFoodRecycleView(){
        binding.foodRecycleView.showShimmer()
        binding.foodRecycleView.layoutManager = GridLayoutManager(
                requireContext(),2
        )
    }
  • 最后运行效果如下图所示:
运行结果
14.前面只是加载时的效果,现在我们做一个加载完的界面效果。新建一个名为food_item的layout文件。
  • 在values文件夹里面的styles.xml中添加几行代码。
<style name="circleImageStyle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">55dp</item>
    </style>
  • 界面搭建和前面差不多,区别就是另外创建了一个ShapeableImageView,然后设置了以下内容。
app:shapeAppearanceOverlay="@style/circleImageStyle"
android:scaleType="centerCrop"
  • 再添加四个TextView和一条线(其实就是一个宽度小一点的view),这个自己布局就好了。最后得到的结果如下图所示:
搭建好的结果
15.搭建好了之后我们要将其显示出来。
  • 在food_item.xml中,选择最上方的<Constraint>然后按'alt'+回车,选择第一个数据绑定。然后添加以下代码。
 <data>
        <variable
            name="result"
            type="com.example.foodresp.data.model.Result" />
    </data>
  • 新建一个FoodAdapter类。
class FoodAdapter() :RecyclerView.Adapter<FoodAdapter.MyViewHolder>(){
    private var recipeList:List<Result> = emptyList()

    class MyViewHolder(private val binding:FoodItemBinding):RecyclerView.ViewHolder(binding.root){
       companion object{
           fun from(parent: ViewGroup):MyViewHolder{
               val inflater = LayoutInflater.from(parent.context)
               val binding = FoodItemBinding.inflate(inflater)
               return MyViewHolder(binding)
           }
       }
        fun bind(result: Result){
            binding.result = result
            binding.executePendingBindings()
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder.from(parent)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(recipeList[position])
    }

    override fun getItemCount(): Int {
        return recipeList.size
    }
}
  • 回到food_item.xml,然后绑定一下数据。下面的tools是默认显示,前面的是我们获取到的数据。
            android:text="@{result.title}"
            tools:text="自制大蒜炸薯条"
android:text="@{String.valueOf(result.readyInMinutes)}"
            tools:text="125"
android:text="@{String.valueOf(result.aggregateLikes)}"
            tools:text="1380"
前面是文本的显示。接下来我们要完成图片的下载与显示。
  • 在gradle里面导入一个插件。
 id 'kotlin-kapt'
  • 进入github官网,搜索glide,导入一下它的依赖库。
 implementation 'com.github.bumptech.glide:glide:4.12.0'
  annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
  • 在recipe包里面创建一个类,名为BindingAdapter
object BindingAdapter {
    @JvmStatic
    @BindingAdapter("loadImageWithUrl")
    fun loadImageWithUrl(imageView: ImageView,url:String){
        //将url对应的图片下载下来,显示到imageView上
        //Glide
        Glide.with(imageView.context)
                .load(url)
                .into(imageView)
    }
}
  • 回到food_item.xml,在<shapeableImageView>里面添加以下内容。
ools:srcCompat="@drawable/ic_launcher_background"
            loadImageWithUrl="@{result.image}"
  • 在foodAdapter类里面添加一个方法。
 fun setData(newData:List<Result>){
        recipeList = newData
        notifyDataSetChanged()
    }
  • 在RecipeFragment里面创建一个FoodAdapter的对象。
private val foodAdapter = FoodAdapter()
  • 然后在initFoodRecycleView()里面绑定adapter
binding.foodRecycleView.adapter = foodAdapter
  • onCreateView里面,不再打印数据,而是完成以下内容。
mainViewModel.recipes.observe(viewLifecycleOwner){
             if(it.results.isNotEmpty()){
               //传递下载的数据
               foodAdapter.setData(it.results)
           }
        }
  • 最后的运行结果如下图所示,(因为用的是国外的网站获取的数据,所以显示的数据都为英文)
最后运行结果
  • 当菜品是中文的时候,我发现它刷新的内容都是一样的,所以最后还是在TypeAdapter的数组里面将菜品类型都改为英文了。这样点击不同的类型,底下也会刷新相应的菜。
十、网络状态
1.新建一个util包,然后创建一个密封类NetWorkResult
sealed class NetWorkResult<T>(
        val data: T ? = null,
        val message:String ?=null){
    class Loading<T>():NetWorkResult<T>()
    class Error<T>(EroMsg:String):NetWorkResult<T>(message = EroMsg)
    class Success<T>(data: T?):NetWorkResult<T>(data)
}
2.在MainViewModel中修改一下recipes的类型。并在里面添加一个判断是否有网络的方法。
class MainViewModel(application: Application) : AndroidViewModel(application){
    //网络请求对象
    private val remoteRepository = RemoteRepository()
    //需要给外部观察
    var recipes: MutableLiveData<NetWorkResult<FoodRecipe>> = MutableLiveData()

    //外部通过这个方法发起网络请求
    fun fetchFoodRecipes(type:String) {
   //处于loading 状态
        recipes.value = NetWorkResult.Loading()
        //判断网络是否有连接
        if (hasInternetConnection()) {
            //处于loading的状态
                recipes.value = NetWorkResult.Loading()
            viewModelScope.launch {
                val response = remoteRepository.fetchFoodRecipes(type)
                if (response.isSuccessful) {
                    //获取数据成功 处于success状态
                    recipes.value = NetWorkResult.Success(response.body())
                }else{
                    //获取数据失败,处于error状态
                    recipes.value = NetWorkResult.Error(response.message())
                }
            }
        }
    }

    //判断是否有网络连接
    private fun hasInternetConnection():Boolean{
        //获取系统的网络链接管理系统
        val connectivityManager = getApplication<Application>()
                .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val activityNetWork = connectivityManager.activeNetwork ?: return false
        val capability = connectivityManager
                .getNetworkCapabilities(activityNetWork)?:return false
        return when{
            capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
            capability.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)-> true
            capability.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)-> true
            else-> false
        }
    }
}
3.在RecipeFragment里面,修改一下mianViewModel的监听事件。
mainViewModel.recipes.observe(viewLifecycleOwner){
           when(it){
               is NetWorkResult.Success -> {
                   binding.foodRecycleView.hideShimmer()
                   foodAdapter.setData(it.data!!.results)
               }
               is NetWorkResult.Loading ->{
                   binding.foodRecycleView.showShimmer()
               }
               is NetWorkResult.Error ->{
                   binding.foodRecycleView.hideShimmer()
                   Toast.makeText(requireContext(),"获取菜单失败:${it.message}",Toast.LENGTH_SHORT)
                           .show()
               }
           }
           }
4.这样运行起来之后就会先显示一下加载页面,然后再显示结果。
运行结果
5.在util包里面新建一个Tools类,在里面写一个提示方法。
fun showToast(context: Context,message: String){
    Toast.makeText(context,"获取菜单失败:${message}", Toast.LENGTH_LONG)
            .show()
}
  • 这样在FoodRecipes里面直接调用该方法即可。
6.在MainViewModel类里面,当没有网络时添加无网络提示。
 //没有网络连接
            showToast(getApplication(),"没有网络连接")
关闭网络后的提示
  • 当关闭网络数据之后又重新启动网络数据会出现错误提示,这是因为我们刷新的时间太快。为了避免这种情况,可以使用try将数据获取的状态括起来。
 try{
                val response = remoteRepository.fetchFoodRecipes(type)
                if (response.isSuccessful) {
                    //获取数据成功 处于success状态
                    recipes.value = NetWorkResult.Success(response.body())
                }else{
                    //获取数据失败,处于error状态
                    recipes.value = NetWorkResult.Error(response.message())
                }
            }catch (e:Exception){
               recipes.value = NetWorkResult.Error("超时了:${e.message!!}")
                }
  • 这样即便刷新很快也不会出现错误提示。
7.当我们关闭网络又重新开开启网络之后,它会重新加载内容,这样就很麻烦。如果我们可以把之前下载好的数据缓存下来,这样重新加载就基本上不会耗时。数据库里面存的是json的字符串。在data包里面创建一个local包,作为本地数据库。那么就要先导入一些依赖库。
    implementation("androidx.room:room-runtime:2.3.0")
   implementation "androidx.room:room-runtime:2.3.0"
    annotationProcessor "androidx.room:room-compiler:2.3.0"
    kapt("androidx.room:room-compiler:2.3.0")

    // LiveData
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0-alpha03")
    // Lifecycles only (without ViewModel or LiveData)
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha03")
8.在local包里面创建一个类RecipeEntity
@Entity(tableName = "foodRecipeTable")
class RecipeEntity (
    @PrimaryKey(autoGenerate = true)
    val id:Int,
    val type :String,
    val recipe: FoodRecipe
        )
9.创建一个接口。里面包含插入数据,查询数据,更新数据等内容。
@Dao
interface RecipeDao {
    //插入数据,如果发现有重复的数据,直接替换
    @Insert(onConflict =OnConflictStrategy.REPLACE)
   suspend fun insertRecipe(recipeEntity: RecipeEntity)

   //查询数据
   @Query("select * from foodRecipeTable where type =:type")
   fun getRecipes(type:String):Flow<List<RecipeEntity>>

   //更新数据
   @Update(onConflict = OnConflictStrategy.REPLACE)
   suspend fun updateRecipe(recipeEntity: RecipeEntity)
}
10.新建一个抽象类RecipeDataBase,继承自room。
@TypeConverters(RecipeTypeConverter::class)
@Database(entities = [RecipeEntity::class],version = 1,exportSchema = false)
abstract class RecipeDataBase:RoomDatabase() {
    abstract fun getRecipeDao():RecipeDao

    companion object{
        private var instance:RecipeDataBase?=null
        fun getInstance(context: Context):RecipeDataBase{
            if(instance!=null){
                return instance!!
            }
            synchronized(this){
                if (instance==null){
                    instance = Room.databaseBuilder(
                        context,RecipeDataBase::class.java,"food_recipe.db"
                    ).build()
                }
                return instance!!
            }
        }
    }
}
11.创建一个类LocalRepository,实现接口里面的那些方法。
class LocalRepository(context: Context) {
    private val recipeDao = RecipeDataBase.getInstance(context ).getRecipeDao()

    //插入数据
    suspend fun insertRecipe(recipeEntity: RecipeEntity){
        recipeDao.insertRecipe(recipeEntity)
    }

    //查询数据
    fun getRecipes(type:String): Flow<List<RecipeEntity>>{
       return recipeDao.getRecipes(type)
    }

    //更新数据
    suspend fun updateRecipe(recipeEntity: RecipeEntity){
        recipeDao.updateRecipe(recipeEntity)
    }
}
12.在util包里面写一个类型转换器RecipeTypeConverter
class RecipeTypeConverter {
    //FoodRecipe ->String
    @TypeConverter
    fun foodRecipeToString(recipe:FoodRecipe):String{
       return Gson().toJson(recipe)
    }
    //String ->FoodRecipe
    @TypeConverter
    fun stringToFoodREcipe(str:String):FoodRecipe{
        return Gson().fromJson(str,FoodRecipe::class.java)
    }
}
13.当我们没有网络的时候,那么就从数据库读取数据。回到mainViewModel类里面,先创建一个数据库对象。
 //数据库的操作对象
    private val localRepository = LocalRepository(getApplication())
  • 然后完成fetchFoodRecipes方法里面没有网络时的功能。
else{
            //没有网络连接
            showToast(getApplication(),"没有网络连接")
            //从数据库中读取数据
            viewModelScope.launch {
                val result = localRepository.getRecipes(type)
                result.collect {
                   if(it.isNotEmpty())
                    val entity = it.first()
                    val data = entity.recipe
                    recipes.value = NetWorkResult.Success(data)
                }}
            }
        }
14.获取数据成功的时候,需要将获取到的数据保存在本地数据库中。
if (response.isSuccessful) {
                    //获取数据成功 处于success状态
                    recipes.value = NetWorkResult.Success(response.body()!!)
                    //需要将数据保存到数据库
                    localRepository.insertRecipe(RecipeEntity(0,type,response.body()!!))
                }else{
                    recipes.value = NetWorkResult.Error(response.message())
                    }
15.这样没有网络的时候还是会加载数据,然后把之前加载过了的数据显示出来。点击未被加载的类型,就会一直加载,然后提示没有网络连接。
没有网状态
16.但是当我们重新打开网络数据的时候,它又会重新从网络上加载数据。但是我们需要的是前面加载过了的数据。所以在判断网络是否连接时之前可以先从数据库中查找,如果没有需要的数据再从网络上获取数据。(这个功能我没做出来,只是建议)
十一、详情页界面
1.当我们点击一个菜品进入详情界面,进行页面跳转时,我们可以添加一些动画效果,这样看起来就会流畅一点。在资源文件anim包里面添加一些xml文件。包括进入和退出。
  • enter_anim.xml
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="100%"
    android:toXDelta="0"
    android:duration="300"
    />
  • exit_anim.xml
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="0"
    android:toXDelta="-100%"
    android:duration="300"
    />
  • pop_enter.xml
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="-100%"
    android:toXDelta="0"
    android:duration="300"
    />
  • pop_exit.xml
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="0"
    android:toXDelta="100%"
    android:duration="300"
    />
然后在my_graph.xml中将动画效果添加进去。
recipeFragment到detailFragment动画
2.在布局之前,我们要先配置一下NavController。在MainActivity里面配置一下NavController,当我们点击下方的控件时,会切换相应的页面。
 val navHost = supportFragmentManager
                .findFragmentById(R.id.fragmentContainerView) as NavHostFragment
        val navController = navHost.navController

        binding.bottomNavigationView.setupWithNavController(navController)
3.然后我们要将菜谱的数据传递到详情页,这里我们就需要添加一个插件。
id 'kotlin-parcelize'
  • 在model包里的result类上方添加以下内容,并让这个类实现Parcelable接口,也就是在最后面加上:Parcelable。同样ExtendedIngredient也序列化一下。
@Parcelize
  • 给detailFragment添加一个Arguments,把Result类添加进来。
4.在recipe包里面再创建一个名为detail的包,在这个包里面新建一个名为DetailFragment的Fragment,然后将多于的代码删掉,只留下一个onCreateView方法。然后使用viewBinding绑定一下。
private lateinit var binding:FragmentDetailBinding
 override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentDetailBinding.inflate(inflater)
        binding.detailBtn.isSelected = true
        return binding.root
    }
5.进入recipe包底下的deatil包,打开DetailFragment,我们要在这里面实现数据接收。
private val recipeArgs:DetailFragmentArgs by navArgs()
那么在foodAdapter里面就要将参数传过去。
fun bind(result: Result){
            binding.result = result
            binding.executePendingBindings()
            binding.foodContainer.setOnClickListener {
                val action = RecipeFragmentDirections
                    .actionRecipeFragmentToDetailFragment(result)
                binding.foodContainer.findNavController().navigate(action)
            }
        }
6.使用约束布局布局一下detail的xml界面。
布局效果
  • 最上方是我们从主界面获取到的图片,显示为圆形,我们直接使用shapeableImageView。然后在styles.xml中将半径设置为这个圆宽度的一半。
<style name="circleImageDetailStyle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">85dp</item>
app:shapeAppearanceOverlay="@style/circleImageDetailStyle"
  • 下方是一个上半圆,底下是矩形。这就是一个view。在drawable里面添加一个资源文件。把它添加到view里面去。
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners
        android:topLeftRadius="56dp"
        android:topRightRadius="56dp"/>
    <solid android:color="#333233"/>
</shape>
android:background="@drawable/top_round_shape"
  • 中间有一些显示标签的控件,比如说是否健康,是否为素食等,它们都是TextView。
  • 最下方有两个控件,分别显示菜谱的材料和详细做法,选择哪边就显示哪个信息。我们用的是view。这个是Detail的view,然后中间再添加一个TextView。另外一个是一样配置的。
<View
            android:id="@+id/indicatorView"
            android:layout_width="0dp"
            android:layout_height="50dp"
            android:background="@drawable/round_corner_shape"
            app:layout_constraintBottom_toBottomOf="@+id/bg"
            app:layout_constraintEnd_toStartOf="@+id/ingredientsBtn"
            app:layout_constraintStart_toStartOf="@+id/detailBtn"
            app:layout_constraintTop_toTopOf="@+id/bg" />
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="28dp"/>
    <solid android:color="#333233"/>
</shape>
十二、数据绑定和选项按钮状态。
1.在detail的xml文件最上方添加data进行数据绑定。
<data>
        <variable
            name="recipe"
            type="com.example.foodresp.data.model.Result" />
    </data>
2.在我们需要显示内容的控件里面添加以下代码,比如下面是我们要显示菜品图片的地方
loadImageWithUrl="@{recipe.image}"
  • 菜品标题
android:text="@{recipe.title}"
  • 做菜时间
android:text="@{String.valueOf(recipe.readyInMinutes)}"
  • 是否便宜的标签。
android:text="Cheap"
changeStatus="@{recipe.cheap}"
  • 是否健康
android:text="Very Healthy"
changeStatus="@{recipe.veryHealthy}"
  • 是否为素食
android:text="Vegan"
changeStatus="@{recipe.vegan}"
3.将绑定的数据传递会DetailFragment。
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.recipe = recipeArgs.recipe
        binding.executePendingBindings()
}
4.实现按钮的返回功能。在DetailFragment里的onViewCreated方法里面实现按钮的点击事件。
binding.backBtn.setOnClickListener {
            requireActivity().onBackPressed()
        }
5.实现点击具体做法(Detail)和原料(Ingredients)时,分别展示不同内容的功能。
  • 先实现切换这两个按钮(严格来说是TextView)的功能。
  • onViewCreated里面实现这两个控件的点击事件,当点击按钮时选中这个控件,然后将另一个改为不选中。
 binding.detailBtn.setOnClickListener {
            binding.detailBtn.isSelected = true
            binding.ingredientsBtn.isSelected = false
        }

binding.ingredientsBtn.setOnClickListener {
           binding.ingredientsBtn.isSelected = true
            binding.detailBtn.isSelected = false
        }
  • 在进行切换时,我们还需要一个动画效果。
private fun indicatorAnim(value:Float){
        binding.indicatorView.animate()
            .translationX(value)
            .setDuration(300)
            .start()
    }
  • 当控件被点击时,并且它之前没有被选中,那么它才有一个移动的动画效果。
binding.ingredientsBtn.setOnClickListener {
           if (!binding.ingredientsBtn.isSelected) {
            binding.ingredientsBtn.isSelected = true
            binding.detailBtn.isSelected = false
            val space = binding.ingredientsBtn.x-binding.detailBtn.x
            indicatorAnim(space)
        }
     }
 binding.detailBtn.setOnClickListener {
        if (!binding.detailBtn.isSelected) {
            binding.detailBtn.isSelected = true
            binding.ingredientsBtn.isSelected = false
            val space = binding.detailBtn.x - binding.ingredientsBtn.x
            indicatorAnim(0f)
        }
      }
十三、ViewPager显示详情内容和原料
1.在xml中添加一个viewPager,就在那两个控件下方。
<androidx.viewpager2.widget.ViewPager2
            android:id="@+id/viewPager"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="@+id/chip6"
            app:layout_constraintStart_toStartOf="@+id/chip4"
            app:layout_constraintTop_toBottomOf="@+id/bg" />
2.在Detail包里面添加两个类SummaryFragmentIngredientFragment用来显示详情界面和原料。
3.在Adapter包里面创建一个类名为ViewPagerAdapter,继承自FragmentStateAdapter,需要传递三个参数,一个是我们的fragment,还有FragmentManager和Lifecycle。然后实现这个父类的两个方法。
class ViewPagerAdapter(
    val fragments:List<Fragment>,
    fm:FragmentManager,
    lifecycle: Lifecycle
):FragmentStateAdapter(fm,lifecycle) {
    override fun getItemCount(): Int {
        return fragments.size
    }

    override fun createFragment(position: Int): Fragment {
        return fragments[position]
    }
}
4.然后在DetailFragment里面写一个initViewPager()方法.
private fun initViewPager(){
        val fragments = listOf(
            SummaryFragment(),
            IngredientFragment())
          binding.viewPager.adapter = ViewPagerAdapter(
            fragments,requireActivity().supportFragmentManager,lifecycle)
    }
5.在SummaryFragment类里面绑定一下。
class SummaryFragment(private val summary:String) : Fragment() {
    private lateinit var binding:FragmentSummaryBinding

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentSummaryBinding.inflate(inflater)
        return binding.root
    }
}
6.在IngredientFragment类里面先绑定一下。
class IngredientFragment  : Fragment() {
private lateinit var binding:FragmentIngredientBinding
override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentIngredientBinding.inflate(inflater)
        return binding.root
    }
}
7.因为两个控件要反复被选中,那个动画也要反复被调用,所以我们将这个选中按钮的方法独立出来。
private fun selectIngredient(){
        if (!binding.ingredientsBtn.isSelected) {
            binding.ingredientsBtn.isSelected = true
            binding.detailBtn.isSelected = false
            val space = binding.ingredientsBtn.x-binding.detailBtn.x
            indicatorAnim(space)
        }
    }
private fun selectDetail(){
        if (!binding.detailBtn.isSelected) {
            binding.detailBtn.isSelected = true
            binding.ingredientsBtn.isSelected = false
            val space = binding.detailBtn.x - binding.ingredientsBtn.x
            indicatorAnim(0f)
        }
    }
8.当选中具体做法控件时,设置它的currentItem为0。选中另一个控件时设为1.
binding.detailBtn.setOnClickListener {
            selectDetail()
            binding.viewPager.currentItem = 0
        }
binding.ingredientsBtn.setOnClickListener {
            selectIngredient()
            binding.viewPager.currentItem = 1
        }
9.给viewPager添加一个监听事件,当viewPager的内容变化时(ViewPager左右拉动可以更改显示内容),选择的控件也会发生变化。
binding.viewPager.registerOnPageChangeCallback(object:ViewPager2.OnPageChangeCallback(){
            override fun onPageSelected(position: Int) {
                if (position == 0){
                    selectDetail()
                }else{
                    selectIngredient()
                }
            }
        })
10.布局一个fragment_summary.xml界面。因为我们要显示能上下滚动的内容,所以我们添加一个ScrollView。再添加一个TextView,也就是菜品的具体做法。
<ScrollView
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">

            <TextView
                android:id="@+id/summaryTextView"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:textColor="#B8B7B8"
                android:textSize="17sp"
                tools:text="summary" />
        </LinearLayout>
    </ScrollView>
  • 将我们获取到的数据显示出来。在summaryFragment类里面添加以下代码。这个类里面需要传一串字符过去。这一串字符就是具体的做法,然后将传递过去的数据在TextView里面显示出来。
 binding.summaryTextView.text = Jsoup.parse(summary).text()
11.在IngredientFragment类里面传一个ExtendedIngredient类型的参数过去。因为原料不止一个,所以是一个数组。
class IngredientFragment(
    private val ingredientList: List<ExtendedIngredient>) : Fragment() {}
12.然后在initViewPager()方法里面,就要将需要的参数传递过去。
private fun initViewPager(){
        val fragments = listOf(
            SummaryFragment(recipeArgs.recipe.summary),
            IngredientFragment(recipeArgs.recipe.extendedIngredients))
          binding.viewPager.adapter = ViewPagerAdapter(
            fragments,requireActivity().supportFragmentManager,lifecycle)
    }
13.这个时候已经可以显示具体的做法了。
具体做法显示结果.png
十四、原料item界面搭建和数据绑定
1.显示原料使用的是recyclerview,所以我们布局fragment_ingredient.xml时,拖一个recyclerview进来,直接使用约束布局,把界面顶满就行。
<androidx.recyclerview.widget.RecyclerView
        android:id="@+id/ingredientRecyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
2.在layout包里面新建一个ingredient_item.xml的文件,在这里我们要设置每一个原料的具体展示情况。这个高度和宽度都是写死的。由一个View,一个ImageView还有三个TextView组成。最外面View的上边、右边和下边都设置一点间距。
ingredient_item布局
  • 这里面使用了一个圆角,我们在drawable里面新建一个资源文件即可。前面有圆角的基本上都是新建了一个资源文件,我没有都写出来。
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="22dp"/>
    <solid android:color="#29262E"/>
</shape>
  • 我们要绑定数据,将这里的数据传递过去。
<data>
        <variable
            name="ingredient"
            type="com.example.foodresp.data.model.ExtendedIngredient" />
    </data>
3.imageView里面调用一个方法显示具体的图片。
loadIngredientImageWithName="@{ingredient.image}"
  • 在TextView里面也要显示我们从网络获取的数据。
android:text="@{ingredient.name}"
tools:text="TextView"
tools:text="123"
android:text="@{String.valueOf(ingredient.amount)}"
tools:text="kg"
android:text="@{ingredient.unit}"
4.在IngredientFragment里面设置以下layout和adapter参数。
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.ingredientRecyclerView.layoutManager = LinearLayoutManager(
            context,RecyclerView.HORIZONTAL,false)
        binding.ingredientRecyclerView.adapter = ingredientAdapter
        ingredientAdapter.setData(ingredientList)
    }
5.新建一个IngredientAdapter,继承自Adapter类,并实现相应的方法。
class IngredientAdapter:RecyclerView.Adapter<IngredientAdapter.MyViewHolder>() {
class MyViewHolder(val binding: IngredientItemBinding)
        :RecyclerView.ViewHolder(binding.root){
         companion object{
            fun from(parent: ViewGroup):MyViewHolder{
                val inflator = LayoutInflater.from(parent.context)
                val binding = IngredientItemBinding.inflate(inflator)
                return MyViewHolder(binding)
            }
        }
    }
 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder.from(parent)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        val ingredient = ingredientList[position]
        holder.bind(ingredient)
    }

    override fun getItemCount(): Int {
        return ingredientList.size
    }
}
6.在MyViewHolder里面添加以下方法进行数据绑定。
 fun bind(ingredient: ExtendedIngredient){
            binding.ingredient = ingredient
            binding.executePendingBindings()
        }
7.在IngredientFragment里面创建IngredientAdapter对象。
private val ingredientAdapter = IngredientAdapter()
  • 然后在onViewCreated里面绑定这个adapter。
binding.ingredientRecyclerView.adapter = ingredientAdapter
8.在IngredientAdapter类里面提供一个方法来接收数据。
 private var ingredientList:List<ExtendedIngredient> = emptyList()
fun setData(newData:List<ExtendedIngredient>){
        ingredientList = newData
        notifyDataSetChanged()
    }
  • onViewCreated把数据传递过去。
ingredientAdapter.setData(ingredientList)
9.在BindingAdapter方法里面将url对应的图片下载下来 显示到imageView上。
 @JvmStatic
    @BindingAdapter("loadIngredientImageWithName")
    fun loadIngredientImageWithName(imageView:ImageView,name:String){
        //将url对应的图片下载下来 显示到imageView上
        //Glide
        //https://spoonacular.com/cdn/ingredients_250x250/
        val imageBaseUrl = "https://spoonacular.com/cdn/ingredients_250x250/"
        Glide.with(imageView.context)
            .load(imageBaseUrl+name)
            .placeholder(R.drawable.ic_launcher_background)
            .into(imageView)
    }
  • .placeholder(R.drawable.ic_launcher_background)表示设置默认图片
10.完成上述步骤后,运行效果如下图所示。
原料显示.png
十五、Room中收藏表创建
1.导入Jsoup的依赖库。
 //Jsoup
    implementation 'org.jsoup:jsoup:1.13.1'
2.然后使用Jsoup来显示text。前面已经写出来了。
binding.summaryTextView.text = Jsoup.parse(summary).text()
3.在data包下的local包里新建一个entity包,把之前的RecipeEntity放进去,然后新建一个FavoriteEntity。作为收藏页面。里面有两个参数,一个是id,一个是食谱对应的字符串,也就是Result类型(这是我们自己定义的食谱类型)。

@Entity(tableName = "favorite_table")
class FavoriteEntity(
    @PrimaryKey(autoGenerate = true)
    var id: Int = 0,
    var result: Result
)
4.在util包的RecipeTypeConverter添加两个方法。把食谱转换为字符串,然后把字符串转换为食谱。
@TypeConverter
    fun resultToString(recipe: Result):String{
        return gson.toJson(recipe)
    }

    @TypeConverter
    fun string2Result(str:String):Result{
        return  gson.fromJson(str,Result::class.java)
    }
5.更新一下RecipeDao里的方法。插入、删除和查询。
//查询
 @Query("select * from favorite_table order by id asc")
    fun getAllFavorites(): Flow<List<FavoriteEntity>>
//插入
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertFavorites(favoritesEntity: FavoriteEntity)
//删除
    @Delete
    suspend fun deleteFavorite(favoritesEntity: FavoriteEntity)
6.在viewmodel包里面创建一个名为FavoriteViewModel的类,继承于AndroidViewModel。然后添加三个查询、插入和删除的方法。
class FavoriteViewModel(application: Application): AndroidViewModel(application){
    private val localRepository = LocalRepository(application)
    val favoriteRecipes:MutableLiveData<List<FavoriteEntity>> = MutableLiveData()

    //查询所有收藏的食谱
    fun readFavorites(){
        viewModelScope.launch {
            localRepository.getAllFavorites().collect {
                favoriteRecipes.value = it
            }
        }
    }

    //插入收藏食谱
    fun insertFavorite(result: Result){
        viewModelScope.launch {
            val favoriteEntity = FavoriteEntity(0,result)
            localRepository.insertFavorite(favoriteEntity)
        }
    }

    //删除收藏
    fun deleteFavorite(favoriteEntity:FavoriteEntity){
        viewModelScope.launch {
            localRepository.deleteFavorite(favoriteEntity)
        }
    }
}
7.点击收藏图标之后,我们要点亮这个图标(其实就是更换一张图片)。那么我们在drawable里面写一个xml文件,如果被选中那么就显示黄色的图片,否则就显示灰色图片。
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/collect_book_mark" android:state_selected="true"/>
    <item android:drawable="@drawable/normal_book_mark" android:state_selected="false"/>
</selector>
8.那么在fragment_detail里面更改一下收藏图标的src。
android:src="@drawable/favorite_mark_selector"
9.然后在DatialFragment里面实现按钮的功能。把这个写在initEvent方法里面。
 binding.collectBtn.setOnClickListener {
            if (binding.collectBtn.isSelected){
                //从数据库收藏表中删除这个食谱
                favoriteViewModel.favoriteRecipes.value?.forEach { entity ->
                    if (entity.result == recipeArgs.recipe){
                        favoriteViewModel.deleteFavorite(entity)
                        binding.collectBtn.isSelected = false
                    }
                }
            }else{
                //将这个食谱插入到收藏表中
                favoriteViewModel.insertFavorite(recipeArgs.recipe)
                binding.collectBtn.isSelected = true
            }
        }
10.在RecipeDatabase里面添加一个FavoriteEntity表。
@Database(
    entities = [RecipeEntity::class, FavoriteEntity::class],
    version = 1,
    exportSchema = false)
11.在onViewCreated方法里面监听一下favoriteViewModel。如果发现它包含收藏里的食谱,那么就将其改为选中状态。
favoriteViewModel.favoriteRecipes.observe(viewLifecycleOwner){
            it.forEach { entity ->
                if(entity.result == recipeArgs.recipe){
                    binding.collectBtn.isSelected = true
                    return@forEach
                }
            }
        }
十六、收藏页面item布局
1.在favorite_fragment.xml里面添加一个背景图片,然后添加一个RecycleView,仍然使用约束布局。
 <ImageView
        android:id="@+id/imageView4"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scaleType="centerCrop"
        android:src="@drawable/main_bg"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        app:layout_constraintBottom_toBottomOf="@+id/imageView4"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
2.新建一个fravorite_item.xml文件,来设计收藏页面中收藏的食谱的布局。
布局效果
3.然后在favorite包里面创建一个FavoriteAdapter类,直接copyFoodAdapter类,然后修改一下参数即可。
class FavoriteAdapter: RecyclerView.Adapter<FavoriteAdapter.MyViewHolder>(){
    private var recipeList:List<Result> = emptyList()

    class MyViewHolder(val binding: FavoriteItemBinding): RecyclerView.ViewHolder(binding.root){
        companion object{
            fun from(parent: ViewGroup):MyViewHolder{
                val inflator = LayoutInflater.from(parent.context)
                 val binding = FavoriteItemBinding.inflate(inflator,parent,false)
                return MyViewHolder(binding)
            }
        }
        fun bind(result: Result){
            binding.recipe = result
            binding.executePendingBindings()
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder.from(parent)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(recipeList[position])
    }

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

    fun setData(newData:List<Result>){
        recipeList = newData
        notifyDataSetChanged()
    }
}
4.然后在favorite_item.xml里面,绑定一个数据。
 <data>
        <variable
            name="recipe"
            type="com.example.foodresp.data.model.Result" />
    </data>
 loadImageWithUrl="@{recipe.image}"
android:text="@{recipe.title}"
android:text="@{String.valueOf(recipe.readyInMinutes)}"
android:text="@{String.valueOf(recipe.aggregateLikes)}"
5.在favoriteFragment里面实现收藏的功能。
class FavoriteFragment : Fragment() {
    private lateinit var binding:FragmentFavoriteBinding
    private val favoriteAdapter = FavoriteAdapter()
    private val favoriteViewModel:FavoriteViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentFavoriteBinding.inflate(inflater)
        binding.recyclerView.layoutManager = LinearLayoutManager(
            context,RecyclerView.VERTICAL,false)
        binding.recyclerView.adapter = favoriteAdapter

        favoriteViewModel.readFavorites()
        favoriteViewModel.favoriteRecipes.observe(viewLifecycleOwner){
            val resultList = mutableListOf<Result>()
            it.forEach { entity ->
                resultList.add(entity.result)
            }
            favoriteAdapter.setData(resultList)
        }
        return binding.root
    }
}
6.最后的运行效果如下图所示:
运行结果
点亮收藏图标
到这里我们的项目就完成了。完整代码https://github.com/magicadong/FoodResp.git
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容