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
四、使用Gson自动创建模型
1.Gson插件的安装见上一篇文章。使用之前先导入一下Gson依赖库
implementation 'com.google.code.gson:gson:2.8.7'
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
//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)
}
}
-
在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。并把<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
}
7.中间很多种菜的类型,那么会有一个当前的默认选中的类型,我们要将那个类型的颜色标亮一点。
-
在clolor包下面创建一个type_item_selector.xml文件,代码如下图所示:
<item android:color="#F5C713" android:state_selected="true"/>
<item android:color="#7C7B7E" android:state_selected="false"/>
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添加的内容如下图所示:
<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>
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"
前面是文本的显示。接下来我们要完成图片的下载与显示。
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}"
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文件。包括进入和退出。
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromXDelta="100%"
android:toXDelta="0"
android:duration="300"
/>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromXDelta="0"
android:toXDelta="-100%"
android:duration="300"
/>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromXDelta="-100%"
android:toXDelta="0"
android:duration="300"
/>
<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包里面添加两个类SummaryFragment
和IngredientFragment
用来显示详情界面和原料。
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.这个时候已经可以显示具体的做法了。
十四、原料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的上边、右边和下边都设置一点间距。
-
这里面使用了一个圆角,我们在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()
}
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.完成上述步骤后,运行效果如下图所示。
十五、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.最后的运行效果如下图所示: