diff --git a/app/build.gradle b/app/build.gradle index 93d31ce..f7ba5f9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,11 +1,16 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'androidx.navigation.safeargs.kotlin' } android { compileSdk 31 + viewBinding { + enabled = true + } + defaultConfig { applicationId "com.github.krottv.tmstemp" minSdk 24 @@ -33,9 +38,21 @@ android { dependencies { + implementation "androidx.navigation:navigation-fragment-ktx:2.4.2" + implementation "androidx.navigation:navigation-ui-ktx:2.4.2" + implementation "androidx.fragment:fragment-ktx:1.4.1" + implementation "androidx.activity:activity-ktx:1.4.0" + + implementation "io.coil-kt:coil:2.0.0-rc03" + + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1' + 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 'com.google.android.material:material:1.6.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.3' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a551aff..e24bb3d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/app/src/main/java/com/github/krottv/tmstemp/binder/MainActivityDataBinder.kt b/app/src/main/java/com/github/krottv/tmstemp/binder/MainActivityDataBinder.kt new file mode 100644 index 0000000..8b81aeb --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/binder/MainActivityDataBinder.kt @@ -0,0 +1,5 @@ +package com.github.krottv.tmstemp.binder + +interface MainActivityDataBinder { + fun bind() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/binder/MainActivityRecyclerScrollDataBinder.kt b/app/src/main/java/com/github/krottv/tmstemp/binder/MainActivityRecyclerScrollDataBinder.kt new file mode 100644 index 0000000..74c73c0 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/binder/MainActivityRecyclerScrollDataBinder.kt @@ -0,0 +1,23 @@ +package com.github.krottv.tmstemp.binder +import android.app.Activity +import android.view.LayoutInflater +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupWithNavController +import com.github.krottv.tmstemp.databinding.ActivityMainBinding + + +class MainActivityRecyclerScrollDataBinder(private val activity: Activity): MainActivityDataBinder { + + override fun bind() { + val container = ActivityMainBinding.inflate(LayoutInflater.from(activity)) + + val navController = container.navHostFragment.getFragment().findNavController() + val appBarConfiguration = AppBarConfiguration(navController.graph) + + container.toolbar.setupWithNavController(navController, appBarConfiguration) + activity.setContentView(container.root) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/binder/RecyclerFragmentBinder.kt b/app/src/main/java/com/github/krottv/tmstemp/binder/RecyclerFragmentBinder.kt new file mode 100644 index 0000000..b640168 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/binder/RecyclerFragmentBinder.kt @@ -0,0 +1,67 @@ +package com.github.krottv.tmstemp.binder + +import android.os.Bundle +import android.transition.* +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.github.krottv.tmstemp.R +import com.github.krottv.tmstemp.databinding.SceneContainerBinding +import com.github.krottv.tmstemp.domain.MessageModel +import com.github.krottv.tmstemp.view.MessageAdapter + +class RecyclerFragmentBinder(private val fragment: Fragment, private val onItemClick: (View, MessageModel) -> Unit) { + + private lateinit var binding: SceneContainerBinding + + fun bindView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = SceneContainerBinding.inflate(inflater, container, false) + + return binding.root + } + + + private val customTransition = TransitionSet().apply { + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + } + + fun onDataLoaded(data: List) { + + val scene = Scene.getSceneForLayout(binding.sceneRoot, R.layout.fragment, fragment.requireContext()) + TransitionManager.go(scene, customTransition) + + val recyclerView = scene.sceneRoot.findViewById(R.id.container) + recyclerView.layoutManager = LinearLayoutManager(fragment.requireContext()) + + if (recyclerView.adapter == null) { + recyclerView.adapter = MessageAdapter(data, onItemClick) + } else { + (recyclerView.adapter as MessageAdapter).data = data + } + } + + fun showError(exception: Throwable) { + + val scene = Scene.getSceneForLayout(binding.sceneRoot, R.layout.error, fragment.requireContext()) + TransitionManager.go(scene, customTransition) + + scene.sceneRoot.findViewById(R.id.errorText).apply { + text = exception.message + } + } + + fun showProgress() { + + val scene = Scene.getSceneForLayout(binding.sceneRoot, R.layout.progress_bar, fragment.requireContext()) + TransitionManager.go(scene, customTransition) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSource.kt b/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSource.kt new file mode 100644 index 0000000..0d2b773 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSource.kt @@ -0,0 +1,7 @@ +package com.github.krottv.tmstemp.data + +import com.github.krottv.tmstemp.domain.MessageModel + +interface RemoteDataSource { + suspend fun getMessages(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSourceError.kt b/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSourceError.kt new file mode 100644 index 0000000..e758343 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSourceError.kt @@ -0,0 +1,12 @@ +package com.github.krottv.tmstemp.data + +import com.github.krottv.tmstemp.domain.MessageModel +import kotlinx.coroutines.delay +import java.lang.IllegalStateException + +class RemoteDataSourceError: RemoteDataSource { + override suspend fun getMessages(): List { + delay(2000) + throw IllegalStateException("IllegalStateException") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSourceFake.kt b/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSourceFake.kt new file mode 100644 index 0000000..9292e11 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/data/RemoteDataSourceFake.kt @@ -0,0 +1,23 @@ +package com.github.krottv.tmstemp.data +import com.github.krottv.tmstemp.domain.MessageModel +import kotlinx.coroutines.delay + +class RemoteDataSourceFake: RemoteDataSource { + + companion object { + const val MESSAGES_COUNT = 1000 + } + override suspend fun getMessages(): List { + delay(2000) + val message = MessageModel( + "https://images.unsplash.com/photo-1568127861543-b0c0696c735f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=470&q=80", + "Пользователь", + "Какой-то текст Какой-то текст Какой-то текст Какой-то текст" + ) + val result = ArrayList(MESSAGES_COUNT) + for (i in 0..MESSAGES_COUNT) { + result.add(message.copy(mainText = "Пользователь $i")) + } + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/domain/MessageModel.kt b/app/src/main/java/com/github/krottv/tmstemp/domain/MessageModel.kt new file mode 100644 index 0000000..8938195 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/domain/MessageModel.kt @@ -0,0 +1,35 @@ +package com.github.krottv.tmstemp.domain +import android.os.Parcel +import android.os.Parcelable +import androidx.annotation.Keep + +@Keep +data class MessageModel(val mainImage: String, val mainText: String, val littleText: String): Parcelable { + constructor(parcel: Parcel) : this( + parcel.readString()!!, + parcel.readString()!!, + parcel.readString()!! + ) + + fun transitionId() = mainText + "_" + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(mainImage) + parcel.writeString(mainText) + parcel.writeString(littleText) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): MessageModel { + return MessageModel(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/presentation/MessageViewModel.kt b/app/src/main/java/com/github/krottv/tmstemp/presentation/MessageViewModel.kt new file mode 100644 index 0000000..17b480c --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/presentation/MessageViewModel.kt @@ -0,0 +1,29 @@ +package com.github.krottv.tmstemp.presentation +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.krottv.tmstemp.data.RemoteDataSource +import com.github.krottv.tmstemp.data.RemoteDataSourceFake +import com.github.krottv.tmstemp.domain.MessageModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class MessageViewModel: ViewModel() { + private val fakeMessages: RemoteDataSource = RemoteDataSourceFake() + + private val _state = MutableStateFlow>?>(null) + val state: StateFlow>?> = _state + + fun loadData() { + viewModelScope.launch(Dispatchers.IO) { + val result = try { + Result.success(fakeMessages.getMessages()) + } catch (exception: Throwable) { + Result.failure(exception) + } + + _state.emit(result) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/view/FullscreenFragment.kt b/app/src/main/java/com/github/krottv/tmstemp/view/FullscreenFragment.kt new file mode 100644 index 0000000..a765aa7 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/view/FullscreenFragment.kt @@ -0,0 +1,78 @@ +package com.github.krottv.tmstemp.view +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.LinearInterpolator +import android.widget.ImageView +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import androidx.transition.AutoTransition +import coil.load +import com.github.krottv.tmstemp.databinding.FullscreenBinding + + +import com.github.krottv.tmstemp.domain.MessageModel +import java.util.concurrent.TimeUnit + +class FullscreenFragment: Fragment() { + + companion object { + const val TRANSITION_ID = "Fullscreen" + } + + private lateinit var binding: FullscreenBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + sharedElementEnterTransition = AutoTransition() + sharedElementReturnTransition = AutoTransition() + + binding = FullscreenBinding.inflate(inflater, container, false) + + binding.root.transitionName = TRANSITION_ID + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val item: MessageModel = requireArguments().getParcelable("currentMessage")!! + + postponeEnterTransition(3000, TimeUnit.MILLISECONDS) + + binding.someText.text = item.littleText + + binding.imageView.load(item.mainImage) { + listener { request, result -> + (view.parent as? View)?.doOnPreDraw { + startPostponedEnterTransition() + } + } + } + + binding.imageView.scaleType = ImageView.ScaleType.CENTER_CROP + + ObjectAnimator().apply { + target = binding.buttonClose + duration = 500 + repeatCount = ObjectAnimator.INFINITE + repeatMode = ObjectAnimator.REVERSE + interpolator = LinearInterpolator() + setValues(PropertyValuesHolder.ofFloat("scaleX", 1f, 1.2f), + PropertyValuesHolder.ofFloat("scaleY", 1f, 1.2f)) + start() + } + + binding.buttonClose.setOnClickListener { + findNavController().navigateUp() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/view/MainActivity.kt b/app/src/main/java/com/github/krottv/tmstemp/view/MainActivity.kt new file mode 100644 index 0000000..5e034c7 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/view/MainActivity.kt @@ -0,0 +1,14 @@ +package com.github.krottv.tmstemp.view +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import com.github.krottv.tmstemp.binder.MainActivityDataBinder +import com.github.krottv.tmstemp.binder.MainActivityRecyclerScrollDataBinder + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val mainActivityDataBinder: MainActivityDataBinder = MainActivityRecyclerScrollDataBinder(this) + mainActivityDataBinder.bind() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/view/MessageAdapter.kt b/app/src/main/java/com/github/krottv/tmstemp/view/MessageAdapter.kt new file mode 100644 index 0000000..9fa1041 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/view/MessageAdapter.kt @@ -0,0 +1,38 @@ +package com.github.krottv.tmstemp.view +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import coil.load +import com.github.krottv.tmstemp.R +import com.github.krottv.tmstemp.domain.MessageModel + +class MessageAdapter(var data: List, private val onItemClick: (View, MessageModel) -> Unit): RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.fragment_scene, parent, false) + return MessageViewHolder(view) + } + + override fun onBindViewHolder(holder: MessageViewHolder, position: Int) { + val cell = data[position] + holder.mainImage.apply { + load(cell.mainImage) + scaleType = ImageView.ScaleType.CENTER_CROP + } + + holder.mainText.text = cell.mainText + holder.littleText.text = cell.littleText + + holder.itemView.transitionName = cell.transitionId() + + holder.itemView.setOnClickListener { + onItemClick(it, cell) + } + } + + override fun getItemCount(): Int { + return data.size + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/view/MessageViewHolder.kt b/app/src/main/java/com/github/krottv/tmstemp/view/MessageViewHolder.kt new file mode 100644 index 0000000..7283d3b --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/view/MessageViewHolder.kt @@ -0,0 +1,12 @@ +package com.github.krottv.tmstemp.view +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.github.krottv.tmstemp.R + +class MessageViewHolder(view: View): RecyclerView.ViewHolder(view) { + val mainImage: ImageView = view.findViewById(R.id.userImage) + val mainText: TextView = view.findViewById(R.id.userText) + val littleText: TextView = view.findViewById(R.id.textFragment) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/krottv/tmstemp/view/RecyclerFragment.kt b/app/src/main/java/com/github/krottv/tmstemp/view/RecyclerFragment.kt new file mode 100644 index 0000000..41769c1 --- /dev/null +++ b/app/src/main/java/com/github/krottv/tmstemp/view/RecyclerFragment.kt @@ -0,0 +1,71 @@ +package com.github.krottv.tmstemp.view +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.Fragment +import androidx.lifecycle.* +import androidx.navigation.fragment.FragmentNavigatorExtras +import androidx.navigation.fragment.findNavController +import androidx.transition.AutoTransition +import com.github.krottv.tmstemp.binder.RecyclerFragmentBinder +import com.github.krottv.tmstemp.presentation.MessageViewModel +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +class RecyclerFragment: Fragment() { + + private lateinit var fragment: RecyclerFragmentBinder + private lateinit var viewModel: MessageViewModel + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + viewModel = ViewModelProvider(this)[MessageViewModel::class.java] + + sharedElementEnterTransition = AutoTransition() + sharedElementReturnTransition = AutoTransition() + + fragment = RecyclerFragmentBinder(this) { view, item -> + val navController = findNavController() + + val action =RecyclerFragmentDirections.actionRecyclerFragmentToFullscreenFragment(item.mainText, item) + val extras = FragmentNavigatorExtras(view to FullscreenFragment.TRANSITION_ID) + + navController.navigate(action, extras) + } + + return fragment.bindView(inflater, container, savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + postponeEnterTransition(1000, TimeUnit.MILLISECONDS) + + viewModel.loadData() + + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { + if (it != null) { + if (it.isSuccess) { + fragment.onDataLoaded(it.getOrThrow()) + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } else { + fragment.showError(it.exceptionOrNull() as Throwable) + } + } else { + fragment.showProgress() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4fc2444..56e2095 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,18 +1,33 @@ - + + android:layout_height="match_parent"> - + + + \ No newline at end of file diff --git a/app/src/main/res/layout/error.xml b/app/src/main/res/layout/error.xml new file mode 100644 index 0000000..6b9cbc9 --- /dev/null +++ b/app/src/main/res/layout/error.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment.xml b/app/src/main/res/layout/fragment.xml new file mode 100644 index 0000000..db5e586 --- /dev/null +++ b/app/src/main/res/layout/fragment.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_scene.xml b/app/src/main/res/layout/fragment_scene.xml new file mode 100644 index 0000000..0c42d94 --- /dev/null +++ b/app/src/main/res/layout/fragment_scene.xml @@ -0,0 +1,42 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fullscreen.xml b/app/src/main/res/layout/fullscreen.xml new file mode 100644 index 0000000..ec7f80c --- /dev/null +++ b/app/src/main/res/layout/fullscreen.xml @@ -0,0 +1,44 @@ + + + + + + + +