Skip to content
This repository was archived by the owner on Jan 5, 2023. It is now read-only.

Commit 31c68c5

Browse files
committed
Codelabs UI for tablets
Changes the Codelabs screen from a 1-column list to a 2-column grid when the screen is wide enough. In the list form, the items expand by tapping. In grid form, the items are already expanded and tapping will launch the codelab. Change-Id: Ie4268144d45cb4ea044300b05aefe3308002ed2a
1 parent 46a9f63 commit 31c68c5

File tree

24 files changed

+304
-172
lines changed

24 files changed

+304
-172
lines changed

mobile/src/main/java/com/google/samples/apps/iosched/ui/codelabs/CodelabsAdapter.kt

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,16 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
3232
import com.google.android.flexbox.FlexboxLayoutManager
3333
import com.google.samples.apps.iosched.R
3434
import com.google.samples.apps.iosched.databinding.ItemCodelabBinding
35-
import com.google.samples.apps.iosched.databinding.ItemCodelabsHeaderBinding
3635
import com.google.samples.apps.iosched.databinding.ItemCodelabsInformationCardBinding
3736
import com.google.samples.apps.iosched.model.Codelab
3837
import com.google.samples.apps.iosched.ui.codelabs.CodelabsViewHolder.CodelabItemHolder
39-
import com.google.samples.apps.iosched.ui.codelabs.CodelabsViewHolder.CodelabsHeaderHolder
4038
import com.google.samples.apps.iosched.ui.codelabs.CodelabsViewHolder.CodelabsInformationCardHolder
4139
import com.google.samples.apps.iosched.util.compatRemoveIf
4240
import com.google.samples.apps.iosched.util.executeAfter
4341

4442
internal class CodelabsAdapter(
4543
private val codelabsActionsHandler: CodelabsActionsHandler,
4644
private val tagViewPool: RecycledViewPool,
47-
private val isMapEnabled: Boolean,
4845
savedState: Bundle?
4946
) : ListAdapter<Any, CodelabsViewHolder>(CodelabsDiffCallback) {
5047

@@ -76,11 +73,9 @@ internal class CodelabsAdapter(
7673
}
7774

7875
override fun getItemViewType(position: Int): Int {
79-
val item = getItem(position)
80-
return when (item) {
76+
return when (val item = getItem(position)) {
8177
is Codelab -> R.layout.item_codelab
8278
is CodelabsInformationCard -> R.layout.item_codelabs_information_card
83-
is CodelabsHeaderItem -> R.layout.item_codelabs_header
8479
else -> throw IllegalStateException("Unknown type: ${item::class.java.simpleName}")
8580
}
8681
}
@@ -104,12 +99,6 @@ internal class CodelabsAdapter(
10499
actionHandler = codelabsActionsHandler
105100
}
106101
)
107-
R.layout.item_codelabs_header -> CodelabsHeaderHolder(
108-
ItemCodelabsHeaderBinding.inflate(inflater, parent, false).apply {
109-
actionHandler = codelabsActionsHandler
110-
mapEnabled = isMapEnabled
111-
}
112-
)
113102
else -> throw IllegalArgumentException("Invalid viewType")
114103
}
115104
}
@@ -126,19 +115,22 @@ internal class CodelabsAdapter(
126115
codelab = item
127116
isExpanded = expandedIds.contains(item.id)
128117
}
129-
holder.itemView.setOnClickListener {
130-
val parent = holder.itemView.parent as? ViewGroup ?: return@setOnClickListener
131-
val expanded = holder.binding.isExpanded ?: false
132-
if (expanded) {
133-
expandedIds.remove(item.id)
134-
} else {
135-
expandedIds.add(item.id)
136-
}
137-
val transition = TransitionInflater.from(holder.itemView.context)
138-
.inflateTransition(R.transition.codelab_toggle)
139-
TransitionManager.beginDelayedTransition(parent, transition)
140-
holder.binding.executeAfter {
141-
isExpanded = !expanded
118+
// In certain configurations the view already has a click listener to start the codelab.
119+
if (!holder.itemView.hasOnClickListeners()) {
120+
holder.itemView.setOnClickListener {
121+
val parent = holder.itemView.parent as? ViewGroup ?: return@setOnClickListener
122+
val expanded = holder.binding.isExpanded ?: false
123+
if (expanded) {
124+
expandedIds.remove(item.id)
125+
} else {
126+
expandedIds.add(item.id)
127+
}
128+
val transition = TransitionInflater.from(holder.itemView.context)
129+
.inflateTransition(R.transition.codelab_toggle)
130+
TransitionManager.beginDelayedTransition(parent, transition)
131+
holder.binding.executeAfter {
132+
isExpanded = !expanded
133+
}
142134
}
143135
}
144136
}
@@ -147,8 +139,6 @@ internal class CodelabsAdapter(
147139
// Marker objects for singleton items
148140
object CodelabsInformationCard
149141

150-
object CodelabsHeaderItem
151-
152142
internal sealed class CodelabsViewHolder(itemView: View) : ViewHolder(itemView) {
153143

154144
class CodelabItemHolder(
@@ -158,18 +148,13 @@ internal sealed class CodelabsViewHolder(itemView: View) : ViewHolder(itemView)
158148
class CodelabsInformationCardHolder(
159149
val binding: ItemCodelabsInformationCardBinding
160150
) : CodelabsViewHolder(binding.root)
161-
162-
class CodelabsHeaderHolder(
163-
val binding: ItemCodelabsHeaderBinding
164-
) : CodelabsViewHolder(binding.root)
165151
}
166152

167153
internal object CodelabsDiffCallback : DiffUtil.ItemCallback<Any>() {
168154

169155
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
170156
return when {
171157
oldItem === CodelabsInformationCard && newItem === CodelabsInformationCard -> true
172-
oldItem === CodelabsHeaderItem && newItem === CodelabsHeaderItem -> true
173158
oldItem is Codelab && newItem is Codelab -> oldItem.id == newItem.id
174159
else -> false
175160
}

mobile/src/main/java/com/google/samples/apps/iosched/ui/codelabs/CodelabsFragment.kt

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ import androidx.fragment.app.activityViewModels
2727
import androidx.fragment.app.viewModels
2828
import androidx.navigation.fragment.findNavController
2929
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
30+
import com.google.samples.apps.iosched.R
3031
import com.google.samples.apps.iosched.databinding.FragmentCodelabsBinding
3132
import com.google.samples.apps.iosched.model.Codelab
3233
import com.google.samples.apps.iosched.shared.analytics.AnalyticsActions
3334
import com.google.samples.apps.iosched.shared.analytics.AnalyticsHelper
3435
import com.google.samples.apps.iosched.shared.di.MapFeatureEnabledFlag
36+
import com.google.samples.apps.iosched.shared.util.consume
3537
import com.google.samples.apps.iosched.ui.MainActivityViewModel
3638
import com.google.samples.apps.iosched.ui.MainNavigationFragment
3739
import com.google.samples.apps.iosched.ui.signin.setupProfileMenuItem
@@ -81,15 +83,27 @@ class CodelabsFragment : MainNavigationFragment(), CodelabsActionsHandler {
8183

8284
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
8385
super.onViewCreated(view, savedInstanceState)
84-
binding.toolbar.setupProfileMenuItem(mainActivityViewModel, viewLifecycleOwner)
86+
binding.toolbar.apply {
87+
setupProfileMenuItem(mainActivityViewModel, viewLifecycleOwner)
88+
menu.findItem(R.id.action_see_on_map)?.isVisible = mapFeatureEnabled
89+
setOnMenuItemClickListener {
90+
when (it.itemId) {
91+
R.id.action_see_on_map -> consume { openCodelabsOnMap() }
92+
R.id.action_codelabs_website -> consume { launchCodelabsWebsite() }
93+
}
94+
false
95+
}
96+
}
8597

8698
codelabsAdapter = CodelabsAdapter(
8799
this,
88100
tagRecycledViewPool,
89-
mapFeatureEnabled,
90101
savedInstanceState
91102
)
92-
binding.codelabsList.adapter = codelabsAdapter
103+
binding.codelabsList.apply {
104+
adapter = codelabsAdapter
105+
setHasFixedSize(true)
106+
}
93107

94108
// Pad the bottom of the RecyclerView so that the content scrolls up above the nav bar
95109
binding.codelabsList.doOnApplyWindowInsets { v, insets, padding ->
@@ -100,7 +114,7 @@ class CodelabsFragment : MainNavigationFragment(), CodelabsActionsHandler {
100114
}
101115

102116
launchAndRepeatWithViewLifecycle {
103-
codelabsViewModel.codelabs.collect {
117+
codelabsViewModel.screenContent.collect {
104118
codelabsAdapter.submitList(it)
105119
}
106120
}
@@ -129,8 +143,10 @@ class CodelabsFragment : MainNavigationFragment(), CodelabsActionsHandler {
129143
}
130144

131145
override fun startCodelab(codelab: Codelab) {
132-
openWebsiteUri(requireContext(), addCodelabsAnalyticsQueryParams(codelab.codelabUrl))
133-
analyticsHelper.logUiEvent("Start codelab \"${codelab.title}\"", AnalyticsActions.CLICK)
146+
if (codelab.hasUrl()) {
147+
openWebsiteUri(requireContext(), addCodelabsAnalyticsQueryParams(codelab.codelabUrl))
148+
analyticsHelper.logUiEvent("Start codelab \"${codelab.title}\"", AnalyticsActions.CLICK)
149+
}
134150
}
135151

136152
private fun addCodelabsAnalyticsQueryParams(url: String): Uri {

mobile/src/main/java/com/google/samples/apps/iosched/ui/codelabs/CodelabsViewModel.kt

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ package com.google.samples.apps.iosched.ui.codelabs
1818

1919
import androidx.lifecycle.ViewModel
2020
import androidx.lifecycle.viewModelScope
21+
import com.google.samples.apps.iosched.model.Codelab
2122
import com.google.samples.apps.iosched.shared.domain.codelabs.GetCodelabsInfoCardShownUseCase
2223
import com.google.samples.apps.iosched.shared.domain.codelabs.LoadCodelabsUseCase
2324
import com.google.samples.apps.iosched.shared.domain.codelabs.SetCodelabsInfoCardShownUseCase
24-
import com.google.samples.apps.iosched.shared.result.Result
2525
import com.google.samples.apps.iosched.shared.result.successOr
2626
import com.google.samples.apps.iosched.util.WhileViewSubscribed
2727
import dagger.hilt.android.lifecycle.HiltViewModel
28-
import kotlinx.coroutines.flow.StateFlow
28+
import kotlinx.coroutines.flow.combine
29+
import kotlinx.coroutines.flow.flow
2930
import kotlinx.coroutines.flow.map
3031
import kotlinx.coroutines.flow.stateIn
3132
import kotlinx.coroutines.launch
@@ -34,25 +35,29 @@ import javax.inject.Inject
3435

3536
@HiltViewModel
3637
class CodelabsViewModel @Inject constructor(
37-
private val loadCodelabsUseCase: LoadCodelabsUseCase,
38-
private val getCodelabsInfoCardShownUseCase: GetCodelabsInfoCardShownUseCase,
38+
loadCodelabsUseCase: LoadCodelabsUseCase,
39+
getCodelabsInfoCardShownUseCase: GetCodelabsInfoCardShownUseCase,
3940
private val setCodelabsInfoCardShownUseCase: SetCodelabsInfoCardShownUseCase
4041
) : ViewModel() {
4142

42-
val codelabs: StateFlow<List<Any>> = getCodelabsInfoCardShownUseCase(Unit).map {
43-
// Refresh codelabs when infoCardShownResult changes.
44-
refreshCodelabs(it)
45-
}.stateIn(viewModelScope, WhileViewSubscribed, emptyList())
43+
private val infoCardDismissed = getCodelabsInfoCardShownUseCase(Unit).map {
44+
it.successOr(false)
45+
}
4646

47-
private suspend fun refreshCodelabs(cardShown: Result<Boolean>): List<Any> {
48-
val codelabs = loadCodelabsUseCase(Unit)
47+
private val codelabs = flow {
48+
emit(loadCodelabsUseCase(Unit).successOr(emptyList()))
49+
}
50+
51+
val screenContent = combine(codelabs, infoCardDismissed) { list, cardDismissed ->
52+
buildScreenContent(list, cardDismissed)
53+
}.stateIn(viewModelScope, WhileViewSubscribed, emptyList())
4954

55+
private fun buildScreenContent(codelabs: List<Codelab>, cardDismissed: Boolean): List<Any> {
5056
val items = mutableListOf<Any>()
51-
if (!cardShown.successOr(false)) {
57+
if (!cardDismissed) {
5258
items.add(CodelabsInformationCard)
5359
}
54-
items.add(CodelabsHeaderItem)
55-
items.addAll(codelabs.successOr(emptyList()))
60+
items.addAll(codelabs)
5661
return items
5762
}
5863

mobile/src/main/java/com/google/samples/apps/iosched/util/ViewBindingAdapters.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import androidx.browser.customtabs.CustomTabsService
3131
import androidx.core.net.toUri
3232
import androidx.core.view.isVisible
3333
import androidx.databinding.BindingAdapter
34+
import androidx.recyclerview.widget.RecyclerView
3435
import androidx.viewpager2.widget.MarginPageTransformer
3536
import androidx.viewpager2.widget.ViewPager2
3637
import com.bumptech.glide.Glide
@@ -41,6 +42,7 @@ import com.google.samples.apps.iosched.R
4142
import com.google.samples.apps.iosched.model.Theme
4243
import com.google.samples.apps.iosched.model.Theme.DARK
4344
import com.google.samples.apps.iosched.widget.CustomSwipeRefreshLayout
45+
import com.google.samples.apps.iosched.widget.SpaceDecoration
4446
import timber.log.Timber
4547

4648
@BindingAdapter("goneUnless")
@@ -125,6 +127,14 @@ fun setText(view: TextView, @StringRes resId: Int) {
125127
}
126128
}
127129

130+
@BindingAdapter("itemSpacing")
131+
fun itemSpacing(view: RecyclerView, dimen: Float) {
132+
val space = dimen.toInt()
133+
if (space > 0) {
134+
view.addItemDecoration(SpaceDecoration(space, space, space, space))
135+
}
136+
}
137+
128138
private const val CHROME_PACKAGE = "com.android.chrome"
129139

130140
@BindingAdapter("websiteLink", "hideWhenEmpty", requireAll = false)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright 2021 Google LLC
4+
~
5+
~ Licensed under the Apache License, Version 2.0 (the "License");
6+
~ you may not use this file except in compliance with the License.
7+
~ You may obtain a copy of the License at
8+
~
9+
~ https://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
-->
17+
18+
<layout
19+
xmlns:android="http://schemas.android.com/apk/res/android"
20+
xmlns:app="http://schemas.android.com/apk/res-auto"
21+
xmlns:tools="http://schemas.android.com/tools">
22+
23+
<data>
24+
25+
<variable
26+
name="codelab"
27+
type="com.google.samples.apps.iosched.model.Codelab" />
28+
29+
<variable
30+
name="actionHandler"
31+
type="com.google.samples.apps.iosched.ui.codelabs.CodelabsActionsHandler" />
32+
</data>
33+
34+
<com.google.android.material.card.MaterialCardView
35+
android:layout_width="match_parent"
36+
android:layout_height="wrap_content"
37+
android:onClick="@{() -> actionHandler.startCodelab(codelab)}"
38+
app:contentPadding="@dimen/margin_normal">
39+
40+
<androidx.constraintlayout.widget.ConstraintLayout
41+
android:layout_width="match_parent"
42+
android:layout_height="wrap_content"
43+
android:minHeight="?listPreferredItemHeight">
44+
45+
<ImageView
46+
android:id="@+id/codelab_icon"
47+
android:layout_width="32dp"
48+
android:layout_height="32dp"
49+
android:contentDescription="@null"
50+
android:layout_marginEnd="@dimen/margin_normal"
51+
app:layout_constraintStart_toStartOf="parent"
52+
app:layout_constraintEnd_toStartOf="@id/codelab_title"
53+
app:layout_constraintTop_toTopOf="parent"
54+
app:imageUrl="@{codelab.iconUrl}"
55+
app:placeholder="@{@drawable/ic_nav_codelabs}"
56+
tools:src="@drawable/ic_nav_codelabs" />
57+
58+
<TextView
59+
android:id="@+id/codelab_title"
60+
android:layout_width="0dp"
61+
android:layout_height="wrap_content"
62+
android:text="@{codelab.title}"
63+
android:textAlignment="viewStart"
64+
android:textAppearance="?textAppearanceListItem"
65+
app:layout_constraintEnd_toEndOf="parent"
66+
app:layout_constraintStart_toEndOf="@id/codelab_icon"
67+
app:layout_constraintTop_toTopOf="parent"
68+
tools:text="@sample/codelabs.json/codelabs/title" />
69+
70+
<TextView
71+
android:id="@+id/codelab_duration"
72+
android:layout_width="wrap_content"
73+
android:layout_height="wrap_content"
74+
android:layout_marginTop="@dimen/spacing_micro"
75+
app:layout_constraintStart_toStartOf="@id/codelab_title"
76+
app:layout_constraintTop_toBottomOf="@id/codelab_title"
77+
app:codelabDuration="@{codelab.durationMinutes}"
78+
tools:text="@sample/codelabs.json/codelabs/duration"
79+
tools:visibility="visible" />
80+
81+
<com.google.samples.apps.iosched.widget.NoTouchRecyclerView
82+
android:id="@+id/codelab_tags"
83+
android:layout_width="0dp"
84+
android:layout_height="wrap_content"
85+
android:orientation="horizontal"
86+
app:layout_constraintEnd_toEndOf="parent"
87+
app:layout_constraintStart_toStartOf="@id/codelab_title"
88+
app:layout_constraintTop_toBottomOf="@id/codelab_duration"
89+
app:topicTags="@{codelab.tags}"
90+
tools:itemCount="3"
91+
tools:layoutManager="LinearLayoutManager"
92+
tools:listitem="@layout/item_inline_tag" />
93+
94+
<TextView
95+
android:id="@+id/codelab_description"
96+
style="@style/Widget.IOSched.MultilineBody"
97+
android:layout_width="0dp"
98+
android:layout_height="wrap_content"
99+
android:layout_marginTop="@dimen/margin_normal"
100+
android:text="@{codelab.description}"
101+
android:textAlignment="viewStart"
102+
app:layout_constraintEnd_toEndOf="parent"
103+
app:layout_constraintStart_toStartOf="@id/codelab_title"
104+
app:layout_constraintTop_toBottomOf="@id/codelab_tags"
105+
tools:text="@sample/codelabs.json/codelabs/description"
106+
tools:visibility="visible" />
107+
108+
</androidx.constraintlayout.widget.ConstraintLayout>
109+
</com.google.android.material.card.MaterialCardView>
110+
</layout>

0 commit comments

Comments
 (0)