无名之辈的Android之路

Android实战:美食项目(三)

2021-09-11  本文已影响0人  搬码人

搜索栏功能实现

TypeAdapter代码
这里要实现的功能:点击搜索栏中的type名称,系统就会将type类型进行传递,使下方的fragment中显示对应类型的美食。但是有几个难点:
1、第一次点击type,type的颜色发生改变,当第二次点击不同的type时,第一次点击的type必须回到原来的颜色,所以这里需要一个lastSelectedPosition参数来记录上一次被点击的type,当两次点击的type不同时,lastSelectedPosition将进行更新;
2、MyViewHolder为我们自己定义的类,是一个嵌套类,与TypeAdapter 的关系不大(或则说没有关系),他不能调用TypeAdapter中的参数,所以这里需要使用高阶函数回调参数的方式在两个类之间传递值。
3、需要将当前点中的type的position和之前点中的position传递给recyclerView,所以这里还需要一个高阶函数回调两个位置数据给RecipeFragment
解释一下这里为什么使用类型转换

val lastTypeHolder = lastHolder as TypeAdapter.MyViewHolder

因为在TypeAdapter.MyViewHolder中才有changeSelectedStatus这个方法,改变type的选中状态

/**
 *@Description
 *@Author PC
 *@QQ 1578684787
 */
class TypeAdapter:RecyclerView.Adapter<TypeAdapter.MyViewHolder>() {
    //事件回调的lambda
    var callBack:((current:Int,last:Int)->Unit)?=null
    val typeList = listOf("main course","side dish","dessert","appetizer","salad","bread",
            "breakfast","beverage","sauce","marinade","finger food","snack","drink")
    private var lastSelectedPosition = 0
    class MyViewHolder(private val binding:ItemTypeBinding):RecyclerView.ViewHolder(binding.root) {
        //数据回调
        var callBack:((Int)->Unit)?=null
        companion object{
            fun from(parent: ViewGroup): MyViewHolder {
                //创建ViewHolder
                val inflater = LayoutInflater.from(parent.context)
                return MyViewHolder(ItemTypeBinding.inflate(inflater))
            }
        }
        //绑定数据
        fun bind(type:String,position: Int){
            binding.titleTextView.text = type
            binding.titleTextView.setOnClickListener {
                callBack?.let {it(position) }
                changeSelectedStatus(true)

            }
        }
        fun changeSelectedStatus(status:Boolean){
            binding.titleTextView.isSelected = status
        }
    }

    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
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(typeList[position],position)
        if (position == lastSelectedPosition){
            holder.changeSelectedStatus(true)
        }else{
            holder.changeSelectedStatus(false)
        }
    }

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

}

在RecipeFragment中
创建fetchData方法从mainViewModel中获取数据

private fun initRecyclerView(){
        //配置类型选择的RecyclerView
        binding.typeRecyclerView.layoutManager = LinearLayoutManager(requireContext(),RecyclerView.HORIZONTAL,false)
        binding.typeRecyclerView.adapter = typeAdapter
        //处理回调事件
        typeAdapter.callBack= {current, last ->
            val currentHolder = binding.typeRecyclerView.findViewHolderForAdapterPosition(current) as TypeAdapter.MyViewHolder
            val lastHolder = binding.typeRecyclerView.findViewHolderForAdapterPosition(last)
            //选中当前
            currentHolder.changeSelectedStatus(true)
            //取消之前选中的
            if (lastHolder != null){
                val lastTypeHolder = lastHolder as TypeAdapter.MyViewHolder
                lastTypeHolder.changeSelectedStatus(false)
            }else{
                //重新把上一次选中的item刷新
                typeAdapter.notifyItemChanged(last)
            }
            //获取数据
            fetchData(typeAdapter.typeList[current])
        }

    }
  private fun fetchData(type:String){
        mainViewModel.fetchFoodRecipes(type)
    }

注:上方else情况必须notifyItemChanged(last)刷新一下,应为这里recyclerView很长,所以会出现type不同时出现在一个屏幕内(简单来说就是当你活动屏幕时之前点击的type不在显示屏内,当你点击另一个type时回到之前的type会发现之前的还处于点击状态),重新刷新后就有选中与不选中的逻辑在里面。
   简单解释一下原因:假如屏幕一次性只能容纳下3个type的显示,滑动到另外三个时之前的三个就会放入栈中(从逻辑上来看,代码中的变量lastHolder此时为null),点击新的type又会将其点亮,回到原来的三个时发生栈的回退,最先点亮的type还是会处于点亮状态,所以在else情况下最好刷新一下。

RecipeFragment代码

fetchData("main source")的原因:默认加载第一个type,也就是main source的数据。
foodAdapter后面显示数据时使用的RecyclerView的adapter。
mainViewModel观察数据的变化中涉及到密封类后面提及。

package com.example.foodresp.fragment.recipe

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.foodresp.utils.NetworkResult
import com.example.foodresp.databinding.FragmentRecipeBinding
import com.example.foodresp.fragment.recipe.adapter.FoodAdapter
import com.example.foodresp.fragment.recipe.adapter.TypeAdapter
import com.example.foodresp.viewModel.MainViewModel


class RecipeFragment : Fragment() {
    private lateinit var binding: FragmentRecipeBinding
    private val typeAdapter = TypeAdapter()
    private val foodAdapter = FoodAdapter()
    private val mainViewModel:MainViewModel by viewModels()
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentRecipeBinding.inflate(inflater)
        initRecyclerView()
        initFoodRecyclerView()
        mainViewModel.recipes.observe(viewLifecycleOwner, {
            when(it){
                is NetworkResult.Success ->{
                    binding.foodRecyclerView.hideShimmer()
                    foodAdapter.setData(it.data!!.results)
                }
                is NetworkResult.Loading ->{
                    binding.foodRecyclerView.showShimmer()
                }
                is NetworkResult.Error ->{
                    binding.foodRecyclerView.hideShimmer()
                    Toast.makeText(requireContext(),"Get Recipes Failed:${it.message}",Toast.LENGTH_SHORT)
                        .show()
                }
            }

        })
       fetchData("main course")
        return binding.root
    }


    private fun initRecyclerView(){
        //配置类型选择的RecyclerView
        binding.typeRecyclerView.layoutManager = LinearLayoutManager(requireContext(),RecyclerView.HORIZONTAL,false)
        binding.typeRecyclerView.adapter = typeAdapter
        //处理回调事件
        typeAdapter.callBack= {current, last ->
            val currentHolder = binding.typeRecyclerView.findViewHolderForAdapterPosition(current) as TypeAdapter.MyViewHolder
            val lastHolder = binding.typeRecyclerView.findViewHolderForAdapterPosition(last)
            //选中当前
            currentHolder.changeSelectedStatus(true)
            //取消之前选中的
            if (lastHolder != null){
                val lastTypeHolder = lastHolder as TypeAdapter.MyViewHolder
                lastTypeHolder.changeSelectedStatus(false)
            }else{
                //重新把上一次选中的item刷新
                typeAdapter.notifyItemChanged(last)
            }
            //获取数据
            fetchData(typeAdapter.typeList[current])
        }

    }
    private fun initFoodRecyclerView(){
        binding.foodRecyclerView.layoutManager = GridLayoutManager(requireContext(),2)
        binding.foodRecyclerView.adapter = foodAdapter

    }

    private fun fetchData(type:String){
        mainViewModel.fetchFoodRecipes(type)
    }
}

美食数据的显示

1、使用RecyclerView显示数据
2、使用shimmerRecyclerView实现镜面加载效果,什么是镜面加载效果:在数据还未下载完成的情况下页面显示数据的轮廓(包括图片、文本等的雏形),相信使用过QQ、微信或者某新闻软件的都明白。showShimmer展示该recyclerView,hideShimmer隐藏该recyclerView


效果展示

导入shimmerRecyclerView的依赖

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

真正的RecyclerView的Item


food_item

使用databinding绑定数据

第一步

创建FoodAdapter ->显示美食数据的recyclerView的adapter

这里的binding.result就是刚在xml文件中定义的result
binding.executePendingBindings() 执行一下绑定的数据

package com.example.foodresp.fragment.recipe.adapter

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.navigation.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.example.foodresp.data.model.Result
import com.example.foodresp.databinding.FoodItemBinding
import com.example.foodresp.fragment.recipe.RecipeFragmentDirections

/**
 *@Description
 *@Author PC
 *@QQ 1578684787
 */
class FoodAdapter:RecyclerView.Adapter<FoodAdapter.MyViewHolder> (){
    private var recipeList:List<Result> = emptyList()
    class MyViewHolder(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()
            binding.foodContainer.setOnClickListener {
                val action = RecipeFragmentDirections.actionRecipeFragmentToDetailFragment(result)
                binding.foodContainer.findNavController().navigate(action)
            }
        }
    }

    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()
    }
}

有关文字类型的数据绑定显示基本相同(见下图),这里唯图片的显示需要不同的操作。

数据绑定

可以看到,在Result数据模型中只有image的名字,所以显示图片需要进一步的操作,这就引出了我们开发中的Glide的使用。

image.png

使用Glide

这里介绍一种特别出名的第三方库Glide,完成图片的下载
(如果要自己实现图片的下载的话大概过程:通过Okhttp下载图片->通过数据流转化为BitMap->使用BitMap显示)

glide依赖的导入

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

图片的路径可以通过在RecipeFragment中打印image来获取
创建BindingAdapter静态类写下载图片的方法,需要使用@JvmStatic(将该方法表示为静态方法,必须有否则后面调用会报错->object中的方法必须是静态方法), @BindingAdapter()这两个注解修饰,第二个注解需要给这个方法一个外部名称,在xml文件中调用时就用这个名称。其余两个方法后续会提及。
Glide需要连两个参数:context以及另一个参数(可以是url、BitMap等),这里需要的是url,因为之前打印image信息显示的是图片路径。

object BindingAdapter {
    @JvmStatic
    @BindingAdapter("loadImageWithUrl")
    fun loadUImageWithUrl(imageView: ImageView,url:String){
        //将url对应的图片下载下来 显示到imageView上
        //Glide
        Glide.with(imageView.context).load(url).into(imageView)
    }

    @JvmStatic
    @BindingAdapter("loadIngredientImageWithName")
    fun loadIngredientImageWithName(imageView: ImageView,name:String){
        //将url对应的图片下载下来 显示到imageView上
        //Glide
        val imageBaseUrl = "https://spoonacular.com/cdn/ingredients_250x250/"
        Glide.with(imageView.context)
                .load(imageBaseUrl+name)
                //设置默认图片 未加载完成时显示的图片
                .placeholder(R.drawable.ic_launcher_background)
                .into(imageView)
    }


    @JvmStatic
    @BindingAdapter("changeStatus")
    fun changeStatus(chip: Chip,status:Boolean){
        chip.isSelected = status
    }

}

只需要传递result.image,第一个参数imageView就是这里的ShapeableImageView,自动将这个控件作为参数传递。

image.png
ShapeableImageView控件:利用shapeAppearanceOverlay属性对该控件进行设计,因为此属性是可以引用的,所以我在values下对控件的属性值进行了设计。(以下不同的style是我根据后面的需要设定的固定值->复用性差)
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="roundedCornerImageStyle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">25dp</item>
    </style>
    <style name="circleImageStyle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">50dp</item>
    </style>
    <style name="circleImageDetailStyle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">85dp</item>
    </style>
    <style name="circleImageFavoriteStyle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">60dp</item>
    </style>
</resources>

使用密封类封装网络状态

image.png
密封类简介

就像我们问什么要使用enum类型一样,比如你有一个类型MoneyUnit,定义了元、角、分这些单位。枚举就是为了控制住你所有要的情况是正确的,而不是用硬编码的方式写字符串“元”,“角”,“分”。
同样,密封类(sealed)的目的类似,一个类子所以设计成sealed,就是为了限制类的继承结构,将一个值在有限集中的类型中,而不能有任何其他的类型。
在某种意义上,sealed类是枚举类的扩展:枚举类型的值集合也是受限的,但每个枚举常量只存在一个实例,而密封类的一个子类可以有可包含状态的多个实例。
要声明一个密封类,需要在类名前面添加sealed修饰符。密封类的所有子类都必须与密封类在同一个文件中声明。
密封类的作用:当一个值必须是一群限制类型中某个类型时,密封类可以表示限制型类的结构,在一定意义上是枚举类的扩展。
密封类与枚举的区别:枚举常量作为单例实体只能存在一个。密封类的子类能有很多实例。
密封类本身是abstract的,因此不能直接实例化,并可以有抽象成员。
密封类默认有private构造器(禁止有非私有的构造器)。

/**
 *@Description
 *@Author PC
 *@QQ 1578684787
 */
/**
 * 密封类
 * 我的理解:根据这里的需要,封装了不同的状态,比如数据加载成功、失败、正在加载……
 */
sealed class NetworkResult<T>(
    val data:T? = null,
    val message:String?=null){
    class Error<T>(errMsg:String): NetworkResult<T>(message = errMsg)
    class Success<T>(data: T?): NetworkResult<T>(data)
    class Loading<T>(): NetworkResult<T>()
}

判断是否有网络连接

//判断是否有网络连接
        private fun hasInternetConnection(): Boolean {
            //获取系统的网络连接管理器
            val connectivityManager = getApplication<Application>()
                .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
            val activeNetwork = connectivityManager.activeNetwork ?: return false
            val capability =
                connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false

            return when {
                //wifi
                capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
                //手机本身的网络 蜂窝网
                capability.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
                //以太网
                capability.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
                else -> false
            }
        }

使用密封类进行封装
先展示部分代码,全部代码在下期完善本地数据库时给全
MainViewModel中
(如果运行过程中报了timeout的异常信息,可以try一下捕获异常)

image.png

RecipeFragment中

image.png

未完待续
项目完整代码

上一篇下一篇

猜你喜欢

热点阅读