Android Native - How to Inject Hilt ViewModels

dimitrilc 4 Tallied Votes 1K Views Share

Introduction

When working with Hilt, you might have wondered how to inject ViewModels into your application. In this tutorial, we will learn how to inject ViewModels into your app Fragments.

Goals

At the end of the tutorial, you would have learned:

  1. How to inject ViewModels into Fragments.
  2. Understand injected ViewModel’s lifecycle.

Tools Required

  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.

Prerequisite Knowledge

  1. Basic Android.
  2. Basic Hilt.

Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Create 3 new Fragments called BlankFragment1, BlankFragment2, and BlankFragment3 by right-clicking the main package > New > Fragment > Fragment (Blank).

  3. Replace the code inside activity_main.xml with the code below. This removes the default “Hello World!” TextView, adds three Buttons aligned on a vertical chain, and a FragmentContainerView.

     <?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">
    
        <Button
            android:id="@+id/button_toFragment1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/to_fragment_1"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
        <Button
            android:id="@+id/button_toFragment2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/to_fragment_2"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/button_toFragment1" />
    
        <Button
            android:id="@+id/button_toFragment3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/to_fragment_3"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/button_toFragment2" />
    
        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/fragmentContainerView"
            android:name="com.codelab.daniwebhiltviewmodels.BlankFragment1"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/button_toFragment3" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  4. Add the code below into MainActivity#onCreate(). It binds the three buttons to an action that will always create the respective Fragment, without any backstack.

     val toFragment1Button = findViewById<Button>(R.id.button_toFragment1)
     val toFragment2Button = findViewById<Button>(R.id.button_toFragment2)
     val toFragment3Button = findViewById<Button>(R.id.button_toFragment3)
    
     toFragment1Button.setOnClickListener{
        supportFragmentManager.commit {
            replace<BlankFragment1>(R.id.fragmentContainerView)
        }
     }
    
     toFragment2Button.setOnClickListener {
        supportFragmentManager.commit {
            replace<BlankFragment2>(R.id.fragmentContainerView)
        }
     }
    
     toFragment3Button.setOnClickListener {
        supportFragmentManager.commit {
            replace<BlankFragment3>(R.id.fragmentContainerView)
        }
     }
  5. Annotate MainActivity and all three Fragment classes with @AndroidEntryPoint.

  6. Add the <string> resources below into your strings.xml.

     <string name="to_fragment_1">To Fragment 1</string>
     <string name="to_fragment_2">To Fragment 2</string>
     <string name="to_fragment_3">To Fragment 3</string>
     <string name="hello_blank_fragment">Hello blank fragment</string>
     <string name="hello_blank_fragment2">Hello blank fragment2</string>
     <string name="hello_blank_fragment3">Hello blank fragment3</string>
  7. Go to each Fragment’s associated layout XML and modify the default TextView’s android:text to the respective <string> resource above.

  8. Properly tag each Fragment with BLANK_FRAGMENT_X, respectively.

  9. Add the dependency below into your Project build.gradle.

     buildscript {
        dependencies {
            classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.5'
        }
     }
  10. Add the plugins for kapt and hilt to your Module build.gradle.

     implementation "com.google.dagger:hilt-android:2.40.5"
     implementation "androidx.fragment:fragment-ktx:1.4.1"
     kapt "com.google.dagger:hilt-compiler:2.40.5"
  11. Also in the Module build.gradle, add the dependencies below.

     implementation "com.google.dagger:hilt-android:2.40.5"
     kapt "com.google.dagger:hilt-compiler:2.40.5"
  12. Create the required Application class for Hilt called MyApplication.kt.

     import android.app.Application
     import dagger.hilt.android.HiltAndroidApp
    
     @HiltAndroidApp
     class ExampleApplication : Application()
  13. Add the attribute below to the manifest <application> element.

     android:name=".ExampleApplication"

Project Overview

Our project so far includes three buttons, which will navigate to three different fragments. At the end of the tutorial, we should have created one ViewModel, which will be injected into the existing fragments. The lifecycle of the ViewModel will behave differently depending how it was injected, so we will observe the output from Logcat to understand what happens to the injected ViewModel instances.

Creating the ViewModel

First, let us create the ViewModel class FragmentViewModel. Copy and paste the code below into a new file named FragmentViewModel.kt.

private const val TAG = "FRAGMENT_VIEW_MODEL"

@HiltViewModel
class FragmentViewModel @Inject constructor(private val application: Application) : ViewModel() {

   override fun onCleared() {
       super.onCleared()

       Log.d(TAG, "ViewModel ${hashCode()} is queued to be destroyed.")
   }

}

In the FragmentViewModel class above, the annotation @HiltViewModel tells Hilt that this ViewModel can be injected into other classes marked with @AndroidEntryPoint as well as allowing Hilt to inject other dependencies into this ViewModel. In this case, we have injected an Application class, which is a special pre-defined binding, only to show that Hilt will assist us in providing instances of this class by injecting this application dependency.

We have also overridden the onCleared() function. This function is called when the Android system destroys the ViewModel.

Hilt ViewModels scoped to Fragment lifecycle

New Hilt ViewModels can be created and injected into each Fragment if requested. This means that each Fragment instance will receive different instances of ViewModel.

In BlankFragment1 and BlankFragment2, perform the steps below:

  1. Add the fragmentViewModel member below. The viewModels() property delegate will provide a ViewModel scoped to the Fragment lifecycle. viewModels() is not only used with Hilt, but it can be used to provide simple ViewModel instances as well when not using Hilt.

     private val fragmentViewModel: FragmentViewModel by viewModels()
  2. Append to onCreate() the code below. This will log when the Fragment is created and which ViewModel instance it receives.

     Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
  3. Override onDestroy() and append the code below. Leave the super() call as-is. This will log when the Fragment is destroyed. Shortly after the Fragment is destroyed, we should be able to see that the ViewModel calls its onClear() function as well.

     Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")

While running the App with the Logcat filter BLANK_FRAGMENT|FRAGMENT_VIEW_MODEL in the Debug channel, we can see that the Fragment/ViewModel pair are being created and destroyed together (when you create another Fragment).

1.jpg

Hilt ViewModels scoped to Activity lifecycle

Hilt can also inject the same ViewModel instance to different instances of the same Fragment. We will apply this behavior to BlankFragment3.

  1. Repeat the steps that you did in the previous section to BlankFragment3.
  2. Replace the viewModel() delegate with activityViewModel(). This will ensure that the ViewModel received is scoped to the Activity’s lifecycle; this can mean the entire application lifecycle in many cases.

When running the app and recreating BlankFragment3 multiple times, we can see that the same ViewModel instance is injected into multiple different instances of BlankFragment3.

2.jpg

Solution Code

MainActivity.kt

package com.codelab.daniwebhiltviewmodels

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val toFragment1Button = findViewById<Button>(R.id.button_toFragment1)
       val toFragment2Button = findViewById<Button>(R.id.button_toFragment2)
       val toFragment3Button = findViewById<Button>(R.id.button_toFragment3)

       toFragment1Button.setOnClickListener{
           supportFragmentManager.commit {
               replace<BlankFragment1>(R.id.fragmentContainerView)
           }
       }

       toFragment2Button.setOnClickListener {
           supportFragmentManager.commit {
               replace<BlankFragment2>(R.id.fragmentContainerView)
           }
       }

       toFragment3Button.setOnClickListener {
           supportFragmentManager.commit {
               replace<BlankFragment3>(R.id.fragmentContainerView)
           }
       }

   }
}

FragmentViewModel.kt

package com.codelab.daniwebhiltviewmodels

import android.app.Application
import android.util.Log
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

private const val TAG = "FRAGMENT_VIEW_MODEL"

@HiltViewModel
class FragmentViewModel @Inject constructor(private val application: Application) : ViewModel() {

   override fun onCleared() {
       super.onCleared()

       Log.d(TAG, "ViewModel ${hashCode()} is queued to be destroyed.")
   }

}

BlankFragment1.kt

package com.codelab.daniwebhiltviewmodels

import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

private const val TAG = "BLANK_FRAGMENT_1"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment1.newInstance] factory method to
* create an instance of this fragment.
*/
@AndroidEntryPoint
class BlankFragment1 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   private val fragmentViewModel: FragmentViewModel by viewModels()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }

       Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       //Log.d(TAG, fragmentViewModel.toString())
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank1, container, false)
   }

   override fun onDestroy() {
       super.onDestroy()
       Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment3.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment1().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

BlankFragment2.kt

package com.codelab.daniwebhiltviewmodels

import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

private const val TAG = "BLANK_FRAGMENT_2"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment2.newInstance] factory method to
* create an instance of this fragment.
*/
@AndroidEntryPoint
class BlankFragment2 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   private val fragmentViewModel: FragmentViewModel by viewModels()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }

       Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank2, container, false)
   }

   override fun onDestroy() {
       super.onDestroy()
       Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment2.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment2().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

BlankFragment3.kt

package com.codelab.daniwebhiltviewmodels

import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

private const val TAG = "BLANK_FRAGMENT_3"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment3.newInstance] factory method to
* create an instance of this fragment.
*/
@AndroidEntryPoint
class BlankFragment3 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   private val fragmentViewModel: FragmentViewModel by activityViewModels()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }

       Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank3, container, false)
   }

   override fun onDestroy() {
       super.onDestroy()
       Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment3.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment3().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

MyApplication.kt

package com.codelab.daniwebhiltviewmodels

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class ExampleApplication : Application()

**activity_main.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">

   <Button
       android:id="@+id/button_toFragment1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/to_fragment_1"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <Button
       android:id="@+id/button_toFragment2"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/to_fragment_2"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/button_toFragment1" />

   <Button
       android:id="@+id/button_toFragment3"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/to_fragment_3"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/button_toFragment2" />

   <androidx.fragment.app.FragmentContainerView
       android:id="@+id/fragmentContainerView"
       android:name="com.codelab.daniwebhiltviewmodels.BlankFragment1"
       android:layout_width="match_parent"
       android:layout_height="0dp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@id/button_toFragment3" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_blank_1.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".BlankFragment1">

   <!-- TODO: Update blank fragment layout -->
   <TextView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:text="@string/hello_blank_fragment" />

</FrameLayout>

fragment_blank_2.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".BlankFragment2">

   <!-- TODO: Update blank fragment layout -->
   <TextView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:text="@string/hello_blank_fragment2" />

</FrameLayout>

fragment_blank_3.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".BlankFragment3">

   <!-- TODO: Update blank fragment layout -->
   <TextView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:text="@string/hello_blank_fragment3" />

</FrameLayout>

strings.xml

<resources>
   <string name="app_name">Daniweb Hilt ViewModels</string>
   <string name="to_fragment_1">To Fragment 1</string>
   <string name="to_fragment_2">To Fragment 2</string>
   <string name="to_fragment_3">To Fragment 3</string>
   <string name="hello_blank_fragment">Hello blank fragment</string>
   <string name="hello_blank_fragment2">Hello blank fragment2</string>
   <string name="hello_blank_fragment3">Hello blank fragment3</string>
</resources>

Project build.gradle

buildscript {
   dependencies {
       classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.5'
   }
}

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
   id 'com.android.application' version '7.1.1' apply false
   id 'com.android.library' version '7.1.1' apply false
   id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
}

task clean(type: Delete) {
   delete rootProject.buildDir
}

Module build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
   id 'kotlin-kapt'
   id 'dagger.hilt.android.plugin'
}

android {
   compileSdk 32

   defaultConfig {
       applicationId "com.codelab.daniwebhiltviewmodels"
       minSdk 21
       targetSdk 32
       versionCode 1
       versionName "1.0"

       testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
   }

   buildTypes {
       release {
           minifyEnabled false
           proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
       }
   }
   compileOptions {
       sourceCompatibility JavaVersion.VERSION_1_8
       targetCompatibility JavaVersion.VERSION_1_8
   }
   kotlinOptions {
       jvmTarget = '1.8'
   }
}

dependencies {
   implementation "com.google.dagger:hilt-android:2.40.5"
   implementation "androidx.fragment:fragment-ktx:1.4.1"
   kapt "com.google.dagger:hilt-compiler:2.40.5"

   implementation 'androidx.legacy:legacy-support-v4:1.0.0'
   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.codelab.daniwebhiltviewmodels">

   <application
       android:name=".ExampleApplication"
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.DaniwebHiltViewModels">
       <activity
           android:name=".MainActivity"
           android:exported="true">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>

Summary

We have learned how to inject ViewModels with Hilt in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebHiltViewModels

nd74850 commented: yes +0
Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.