Build a Recipe App using MVVM Architecture with Kotlin in Android
In this article, we will make a recipe app that displays a list of Indian recipes using the retrofit library and MVVM architecture. Model — View — ViewModel (MVVM) is the industry-recognized software architecture pattern that overcomes all drawbacks of MVP and MVC design patterns. MVVM suggests separating the data presentation logic(Views or UI) from the core business logic part of the application. We will fetch data from The Meal DB website. A sample video is given below to get an idea about what we are going to do in this article.
The separate code layers of MVVM are:
- Model: This layer is responsible for the abstraction of the data sources. Model and ViewModel work together to get and save the data.
- View: The purpose of this layer is to inform the ViewModel about the user’s action. This layer observes the ViewModel and does not contain any kind of application logic.
- ViewModel: It exposes those data streams which are relevant to the View. Moreover, it serves as a link between the Model and the View.
Step by Step Implementation
Step 1: Create a New Project in Android Studio
To create a new project in Android Studio please refer to How to Create/Start a New Project in Android Studio. Note that select Kotlin as the programming language.
Step 2: Add Required Dependencies
Add Retrofit dependencies in Build.gradle(app) file
Kotlin
// retrofit implementation( "com.squareup.retrofit2:retrofit:2.9.0" ) implementation 'com.squareup.retrofit2:converter-gson:2.3.0' |
Add View Model and Live Data dependencies in Build.gradle(app) file
Kotlin
def lifecycle_version = "2.6.0-alpha01" // ViewModel implementation( "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" ) // ViewModel utilities for Compose implementation( "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" ) // LiveData implementation( "androidx.lifecycle:lifecycle-live data-ktx:$lifecycle_version" ) |
Add Glide Dependency in Build.gradle(app) file
Kotlin
implementation 'com.github.bumptech.glide:glide:4.13.2' |
Glide library helps in image processing in android.
Step 3: Enable View Binding
To enable view binding to add this code inside the android {} block in build.gradle(app) file
Kotlin
buildFeatures { viewBinding = true } |
Step 4: Generate data classes
Right-click on the root package and select New > Kotlin-data class from JSON. If you don’t have this plugin, go to File -> Settings -> Plugin and install JSON to Kotlin Plugin. Copy the JSON result and paste it. Give this file a suitable name after that data classes will be generated.
Kotlin
data class Meal( val idMeal: String, val strMeal: String, val strMealThumb: String ) |
Kotlin
data class Recipe( val meals: List<Meal> ) |
Kotlin
data class MealsByCategoryName( val dateModified: Any, val idMeal: String, val strArea: String?, val strCategory: String?, val strCreativeCommonsConfirmed: Any?, val strDrinkAlternate: Any?, val strImageSource: Any?, val strIngredient1: String?, val strIngredient10: String?, val strIngredient11: String?, val strIngredient12: String?, val strIngredient13: String?, val strIngredient14: String?, val strIngredient15: String?, val strIngredient16: String?, val strIngredient17: String?, val strIngredient18: String?, val strIngredient19: String?, val strIngredient2: String?, val strIngredient20: String?, val strIngredient3: String?, val strIngredient4: String?, val strIngredient5: String?, val strIngredient6: String?, val strIngredient7: String?, val strIngredient8: String?, val strIngredient9: String?, val strInstructions: String?, val strMeal: String?, val strMealThumb: String?, val strMeasure1: String?, val strMeasure10: String?, val strMeasure11: String?, val strMeasure12: String?, val strMeasure13: String?, val strMeasure14: String?, val strMeasure15: String?, val strMeasure16: String?, val strMeasure17: String?, val strMeasure18: String?, val strMeasure19: String?, val strMeasure2: String?, val strMeasure20: String?, val strMeasure3: String?, val strMeasure4: String?, val strMeasure5: String?, val strMeasure6: String?, val strMeasure7: String?, val strMeasure8: String?, val strMeasure9: String?, val strSource: String?, val strTags: Any?, val strYoutube: String? ) |
Right Click on the Root package and create an interface MealAPi
Kotlin
interface MealApi { @GET ( "filter.php?" ) fun getRecipeByCountryName( @Query ( "a" ) countryName : String) : Call<Recipe> } |
Create a Retrofit Instance
Kotlin
object MealInstance { val api : MealApi by lazy { Retrofit.Builder() .baseUrl( "https://www.themealdb.com/api/json/v1/1/" ) .addConverterFactory(GsonConverterFactory.create()) .build() .create(MealApi:: class .java) } } |
Step 5: Design layout Files
Navigate to the app > res > layout > activity_main.xml and add the below code to that file. Below is the code for the activity_main.xml file. Comments are added inside the code to understand the code in more detail.
XML
<? xml version = "1.0" encoding = "utf-8" ?> < androidx.constraintlayout.widget.ConstraintLayout xmlns:android = "http://schemas.android.com/apk/res/android" xmlns:app = "http://schemas.android.com/apk/res-auto" xmlns:tools = "http://schemas.android.com/tools" android:layout_width = "match_parent" android:layout_height = "match_parent" tools:context = ".MainActivity" > < androidx.recyclerview.widget.RecyclerView android:id = "@+id/recycler_view" android:layout_width = "match_parent" android:layout_height = "match_parent" app:layout_constraintBottom_toBottomOf = "parent" app:layout_constraintEnd_toEndOf = "parent" app:layout_constraintStart_toStartOf = "parent" app:layout_constraintTop_toTopOf = "parent" tools:listitem = "@layout/recipe_layout" > </ androidx.recyclerview.widget.RecyclerView > |
Since we are using recycler view we need to create a new layout file for that go to res->layout and create a new Layout resource file named recipe_layout.
recipe_layout.xml file
XML
< androidx.constraintlayout.widget.ConstraintLayout xmlns:android = "http://schemas.android.com/apk/res/android" android:layout_width = "wrap_content" android:layout_height = "wrap_content" android:layout_margin = "10dp" android:layout_marginRight = "5dp" xmlns:app = "http://schemas.android.com/apk/res-auto" > < ImageView android:id = "@+id/recipe_image" android:layout_width = "200dp" android:layout_height = "200dp" app:layout_constraintStart_toStartOf = "parent" app:layout_constraintTop_toTopOf = "parent" app:layout_constraintEnd_toEndOf = "parent" android:scaleType = "fitCenter" android:src = "@color/teal_200" > </ ImageView > < TextView android:id = "@+id/recipe_name" android:layout_width = "wrap_content" android:layout_height = "wrap_content" app:layout_constraintStart_toStartOf = "parent" app:layout_constraintEnd_toEndOf = "parent" app:layout_constraintTop_toBottomOf = "@id/recipe_image" android:textSize = "30dp" android:text = "Recipe Name" android:textAlignment = "center" android:textColor = "@color/black" android:textStyle = "bold" android:layout_marginTop = "5dp" > </ TextView > </ androidx.constraintlayout.widget.ConstraintLayout > |
We will also require another activity which will be used to display recipe instructions. We will use a collapsing ToolBar such that instructions are visible clearly.
activity_meal.xml
XML
<? xml version = "1.0" encoding = "utf-8" ?> < androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android = "http://schemas.android.com/apk/res/android" xmlns:app = "http://schemas.android.com/apk/res-auto" xmlns:tools = "http://schemas.android.com/tools" android:layout_width = "match_parent" android:layout_height = "match_parent" tools:context = ".ui.activity.MealActivity" > < com.google.android.material.appbar.AppBarLayout android:id = "@+id/appBar" android:layout_width = "match_parent" android:layout_height = "280dp" android:backgroundTint = "@color/g_black" > < com.google.android.material.appbar.CollapsingToolbarLayout android:id = "@+id/collapsingToolBar" android:layout_width = "match_parent" android:layout_height = "match_parent" app:contentScrim = "@color/accent" app:layout_scrollFlags = "scroll|snap|exitUntilCollapsed" app:title = "Meal Name" > < ImageView android:id = "@+id/img_meal_detail" android:layout_width = "match_parent" android:layout_height = "wrap_content" android:scaleType = "centerCrop" app:layout_collapseMode = "parallax" > </ ImageView > < androidx.appcompat.widget.Toolbar android:id = "@+id/toolbar" android:layout_width = "match_parent" android:layout_height = "?actionBarSize" app:layout_collapseMode = "pin" app:titleTextColor = "@color/white" > </ androidx.appcompat.widget.Toolbar > </ com.google.android.material.appbar.CollapsingToolbarLayout > </ com.google.android.material.appbar.AppBarLayout > < com.google.android.material.floatingactionbutton.FloatingActionButton android:id = "@+id/btn_fav" android:layout_width = "match_parent" android:layout_height = "wrap_content" android:layout_marginEnd = "@dimen/_10sdp" android:backgroundTint = "@color/accent" android:src = "@drawable/favorite" android:tint = "@color/white" app:layout_anchor = "@id/appBar" app:layout_anchorGravity = "bottom|end" /> < androidx.core.widget.NestedScrollView android:layout_width = "match_parent" android:layout_height = "match_parent" app:layout_behavior = "com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" > < androidx.constraintlayout.widget.ConstraintLayout android:layout_width = "match_parent" android:layout_height = "match_parent" android:layout_marginTop = "5dp" android:layout_marginBottom = "45dp" > < LinearLayout android:id = "@+id/linear_layout" android:layout_width = "match_parent" android:layout_height = "match_parent" android:orientation = "horizontal" app:layout_constraintTop_toTopOf = "parent" app:layout_constraintStart_toStartOf = "parent" > < TextView android:id = "@+id/tv_category" android:layout_width = "wrap_content" android:layout_height = "wrap_content" android:text = "Category : Pizza" android:textStyle = "bold" android:textColor = "@color/g_black" android:drawableLeft = "@drawable/category" android:backgroundTint = "@color/g_black" android:layout_weight = "1" > </ TextView > < TextView android:id = "@+id/tv_area" android:layout_width = "wrap_content" android:layout_height = "wrap_content" android:text = "Area : Palestine" android:textStyle = "bold" android:textColor = "@color/g_black" android:drawableLeft = "@drawable/area" android:backgroundTint = "@color/g_black" android:layout_weight = "1" > </ TextView > </ LinearLayout > < TextView android:id = "@+id/instructions" android:layout_width = "wrap_content" android:layout_height = "wrap_content" android:text = "-Instructions:" android:textColor = "@color/black" android:textStyle = "bold" app:layout_constraintStart_toStartOf = "parent" app:layout_constraintTop_toBottomOf = "@id/linear_layout" android:layout_marginTop = "@dimen/_10sdp" android:layout_marginStart = "@dimen/_5sdp" android:textSize = "@dimen/_15sdp" android:fontFamily = "@font/myfont" > </ TextView > < TextView android:id = "@+id/tv_instructions_steps" android:layout_width = "wrap_content" android:layout_height = "wrap_content" android:textColor = "@color/black" app:layout_constraintStart_toStartOf = "@id/instructions" app:layout_constraintTop_toBottomOf = "@id/instructions" android:layout_marginTop = "@dimen/_2sdp" > </ TextView > </ androidx.constraintlayout.widget.ConstraintLayout > </ androidx.core.widget.NestedScrollView > < com.google.android.material.progressindicator.LinearProgressIndicator android:id = "@+id/progress_bar" android:layout_width = "match_parent" android:layout_height = "wrap_content" android:layout_gravity = "bottom" app:layout_anchor = "@id/appBar" android:indeterminate = "true" > </ com.google.android.material.progressindicator.LinearProgressIndicator > </ androidx.coordinatorlayout.widget.CoordinatorLayout > |
Step 6: Create a Movie Adapter class for RecyclerView
Create a MealAdapter class and set up onClickListener on each item
Kotlin
class MealAdapter : RecyclerView.Adapter<MealAdapter.ViewHolder>() { private var listOfMeals = ArrayList<Meal>() private lateinit var setOnMealClickListener : SetOnMealClickListener fun setOnMealClickListener(setOnMealClickListener: SetOnMealClickListener){ this .setOnMealClickListener = setOnMealClickListener } fun setMealData(listOfMeals : List<Meal>) { this .listOfMeals = listOfMeals as ArrayList<Meal> notifyDataSetChanged() } class ViewHolder(val binding: RecipeLayoutBinding) : RecyclerView.ViewHolder(binding.root){} override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder( RecipeLayoutBinding.inflate( LayoutInflater.from( parent.context ) ) ) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { Glide.with(holder.itemView).load(listOfMeals[position].strMealThumb).into(holder.binding.recipeImage) holder.binding.recipeName.text= listOfMeals[position].strMeal } override fun getItemCount(): Int { return listOfMeals.size } interface SetOnMealClickListener { fun setOnClickListener(mealsByCategoryName: MealsByCategoryName) } } |
Step 7: Create Movie View Model
Since we are using MVVM architecture we need to create a View Model Class with live data in it.
Kotlin
class RecipeViewModel : ViewModel() { private var recipeLiveData = MutableLiveData<List<Meal>>() fun getMealsByCountryName(){ MealInstance.api.getRecipeByCountryName( "Indian" ).enqueue(object : Callback<Recipe>{ override fun onResponse(call: Call<Recipe>, response: Response<Recipe>) { response.body()?.let { mealsList-> recipeLiveData.postValue(mealsList.meals) } } override fun onFailure(call: Call<Recipe>, t: Throwable) { Log.i( "TAG" , t.message.toString()) } }) } fun observeLiveData() : LiveData<List<Meal>> { return recipeLiveData } } |
Step 8: Write Code for UI
In the end, we will write code for the main activity and meal activity which represents our UI
MainActivity.kt file:
Kotlin
class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var viewModel: RecipeViewModel private lateinit var mealAdapter: MealAdapter companion object { const val MEAL_ID = "com.sangyan.easyfood.idMeal" const val MEAL_Name = "com.sangyan.easyfood.nameMeal" const val MEAL_THUMB = "com.sangyan.easyfood.thumbMeal" } override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) prepareRecyclerView() viewModel = ViewModelProvider( this )[RecipeViewModel:: class .java] viewModel.getMealsByCountryName() viewModel.observeLiveData().observe( this , Observer { it -> mealAdapter.setMealData(it) }) mealAdapter.setOnMealClickListener(object : MealAdapter.SetOnMealClickListener{ override fun setOnClickListener(mealsByCategoryName: MealsByCategoryName) { val intent = Intent(applicationContext, MealActivity:: class .java) intent.putExtra(MEAL_ID, mealsByCategoryName.idMeal) intent.putExtra(MEAL_Name, mealsByCategoryName.strMeal) intent.putExtra(MEAL_THUMB, mealsByCategoryName.strMealThumb) startActivity(intent) } }) } private fun prepareRecyclerView() { mealAdapter = MealAdapter() binding.recyclerView.apply { layoutManager = GridLayoutManager(applicationContext , 2 ) adapter = mealAdapter } } } |
MealActivity.kt file:
Kotlin
class MealActivity : AppCompatActivity() { private lateinit var binding : ActivityMealBinding private lateinit var mealId : String private lateinit var mealName : String private lateinit var mealThumb : String private lateinit var mealMVVM : MealViewModel private lateinit var youtubeLink : String override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) binding = ActivityMealBinding.inflate(layoutInflater) setContentView(binding.root) getMealsFromCountryIntent() setInformationViews() val meadDatabase = MealDatabase.getInstance( this ) val viewModelFactory = MealViewModelFactory(meadDatabase) mealMVVM = ViewModelProvider( this ,viewModelFactory)[MealViewModel:: class .java] mealMVVM.getMealDetails(mealId) observeMealDetailsLiveData() } private var mealToSave : Meal?= null private fun observeMealDetailsLiveData() { mealMVVM.observeMealDetailsLiveData().observe( this ,object : Observer<Meal>{ override fun onChanged(t: Meal?) { val meal = t mealToSave = meal binding.tvCategory.text = "Category : ${meal!!.strCategory}" binding.tvArea.text = "Area : ${meal.strArea}" binding.tvInstructionsSteps.text = meal.strInstructions } }) } private fun setInformationViews() { Glide.with(applicationContext) .load(mealThumb) .into(binding.imgMealDetail) binding.collapsingToolBar.title = mealName binding.collapsingToolBar.setCollapsedTitleTextColor(resources.getColor(R.color.white)) binding.collapsingToolBar.setExpandedTitleColor(resources.getColor(R.color.white)) } private fun getMealsFromCountryIntent() { mealId = intent.getStringExtra(MainActivity.MEAL_ID)!! mealName = intent.getStringExtra(MainActivity.MEAL_Name)!! mealThumb = intent.getStringExtra(MainActivity.MEAL_THUMB)!! } } |
Output:
Contact Us