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
}
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{
       return foodApi.fetchFoodRecipes(type)
    }
}
3.创建一个名为viewmodel的包,新建一个MainViewModel类。
class MainViewModel(application: Application) :AndroidViewModel(application){
    //网络请求对象
    private val remoteRepository = RemoteRepository()
    //需要给外部观察
    var recipes:MutableLiveData = 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-started和https://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。并把改为
  
5.在themes.xml里面把Bar 的颜色改为我们的主题颜色。
#3E3933
运行结果,最上方的颜色为黑色
八、添加BottomNavigationView
1.添加几张menu图片。在drawable里面new一个Vector Asset,然后点击Clip Art搜索book,就可以得到一个book图标。同理另外三个也是一样。
三个图标
2.新建一个menu的directory,新建一个resource file,在里面新建几个item,把我们之前创建的那些fragment都加进去。id对应的就是Fragment的id

    
    
3.在activity_main里面添加一个BottomNavigationView,放在containerNavigation下面。BottomNavigationView里面有一个menu属性,把menu添加进去就行了。
 app:menu="@menu/bottom_menu"
4.改一下控件的颜色。在themes.xml中将将代码按如下所示修改。
#F5C713
5.让图标和文字在被选中时为黄色,未被选中时为灰色。
  • 创建一个类型为color的Android Resource Directory,然后在里面添加两个item,包含选中和未选中时的颜色。
    
    
  • 在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。
 
4.在fragment_recipes里面添加一个ShapeableImageView。这个就是我们右上角显示的头像。

5.然后在它们下方在添加一张一盘菜的长形图片。到现在我们搭建好的页面如下图所示:
目前搭建好的页面
6.添加一个recycleView,它表示用户最近的搜索记录,是一个横向滚动的recycleView。
  • 先在fragment_recipe里面添加一个recycleView。然后新建一个item_type.xml文件,在里面添加一个TextView,使用约束布局,让父容器的宽和高都为wrap_content。
  • 创建一个TypeAdapter类。
class TypeAdapter: RecyclerView.Adapter() {
    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_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添加的内容如下图所示:
    
    
  • 让后将view的background设为该资源文件。把view的高度和宽度写死,分别为193dp和159dp。
  • 在drawable里面新建一个资源文件circle_shape.xml,其代码如下所示:

    
    

  • 添加一个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中添加几行代码。

  • 界面搭建和前面差不多,区别就是另外创建了一个ShapeableImageView,然后设置了以下内容。
app:shapeAppearanceOverlay="@style/circleImageStyle"
android:scaleType="centerCrop"
  • 再添加四个TextView和一条线(其实就是一个宽度小一点的view),这个自己布局就好了。最后得到的结果如下图所示:
搭建好的结果
15.搭建好了之后我们要将其显示出来。
  • 在food_item.xml中,选择最上方的然后按'alt'+回车,选择第一个数据绑定。然后添加以下代码。
 
        
    
  • 新建一个FoodAdapter类。
class FoodAdapter() :RecyclerView.Adapter(){
    private var recipeList:List = 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,在里面添加以下内容。
ools:srcCompat="@drawable/ic_launcher_background"
            loadImageWithUrl="@{result.image}"
  • 在foodAdapter类里面添加一个方法。
 fun setData(newData:List){
        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(
        val data: T ? = null,
        val message:String ?=null){
    class Loading():NetWorkResult()
    class Error(EroMsg:String):NetWorkResult(message = EroMsg)
    class Success(data: T?):NetWorkResult(data)
}
2.在MainViewModel中修改一下recipes的类型。并在里面添加一个判断是否有网络的方法。
class MainViewModel(application: Application) : AndroidViewModel(application){
    //网络请求对象
    private val remoteRepository = RemoteRepository()
    //需要给外部观察
    var recipes: MutableLiveData> = 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()
                .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>

   //更新数据
   @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>{
       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

  • exit_anim.xml

  • pop_enter.xml

  • pop_exit.xml

然后在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中将半径设置为这个圆宽度的一半。