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查看食谱的接口。
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解析器把里面的内容解析出来。
四、使用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")
id 'androidx.navigation.safeargs.kotlin'
-
在build.gradel的project里面添加一个classPath
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
2.创建一个名为fragments的包,在里面新建几个fragment,它会自动生成代码和xml文件,然后删掉我们不需要的冗余代码。
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)
}
}
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)
}
}
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都添加进来。
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
}
7.中间很多种菜的类型,那么会有一个当前的默认选中的类型,我们要将那个类型的颜色标亮一点。
-
在clolor包下面创建一个type_item_selector.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
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'+回车,选择第一个数据绑定。然后添加以下代码。
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"
前面是文本的显示。接下来我们要完成图片的下载与显示。
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}"
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文件。包括进入和退出。
然后在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中将半径设置为这个圆宽度的一半。