Skip to content

Commit 19ff2d3

Browse files
authored
Logo animation on Input Screen (#6821)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1210978319570196?focus=true ### Description - Animates the logo when moving between search and Duck.ai on the Input Screen ### Steps to test this PR _Empty state_ - [ ] Open the Input Screen - [ ] Swipe between tabs - [ ] Verify that the logo and tab indicator animate as expected - [ ] Tap the tabs - [ ] Verify that the logo animates as expected _Favorites_ - [ ] Add some favorites - [ ] Open the Input Screen - [ ] Swipe between tabs - [ ] Verify that the Duck.ai logo fades in (no animation) - [ ] Tap the tabs - [ ] Verify that the Duck.ai logo fades in (no animation) ### UI changes https://github.com/user-attachments/assets/e2459857-34a0-4274-87f8-31e4ab6c9835 https://github.com/user-attachments/assets/a0d75565-44bc-4a3a-91f7-bc69871f5937
1 parent c17b34e commit 19ff2d3

File tree

10 files changed

+308
-17
lines changed

10 files changed

+308
-17
lines changed

duckchat/duckchat-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ dependencies {
7171
exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug"
7272
}
7373
testImplementation AndroidX.lifecycle.runtime.testing
74+
testImplementation AndroidX.archCore.testing
7475

7576
coreLibraryDesugaring Android.tools.desugarJdkLibs
7677
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenFragment.kt

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.duckduckgo.duckchat.impl.inputscreen.ui
1818

19+
import android.animation.ValueAnimator
1920
import android.app.Activity
2021
import android.content.Intent
2122
import android.os.Bundle
@@ -25,10 +26,12 @@ import androidx.core.view.isInvisible
2526
import androidx.core.view.isVisible
2627
import androidx.lifecycle.ViewModelProvider
2728
import androidx.lifecycle.lifecycleScope
29+
import androidx.viewpager2.widget.ViewPager2
2830
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
2931
import com.duckduckgo.anvil.annotations.InjectWith
3032
import com.duckduckgo.app.statistics.pixels.Pixel
3133
import com.duckduckgo.common.ui.DuckDuckGoFragment
34+
import com.duckduckgo.common.ui.store.AppTheme
3235
import com.duckduckgo.common.ui.viewbinding.viewBinding
3336
import com.duckduckgo.common.utils.extensions.hideKeyboard
3437
import com.duckduckgo.common.utils.extensions.showKeyboard
@@ -39,8 +42,11 @@ import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultParams
3942
import com.duckduckgo.duckchat.impl.R
4043
import com.duckduckgo.duckchat.impl.databinding.FragmentInputScreenBinding
4144
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command
45+
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.AnimateLogoToProgress
4246
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.EditWithSelectedQuery
4347
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.HideKeyboard
48+
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.SetInputModeWidgetScrollPosition
49+
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.SetLogoProgress
4450
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.ShowKeyboard
4551
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.SubmitChat
4652
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.SubmitSearch
@@ -80,6 +86,9 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
8086
@Inject
8187
lateinit var viewModelFactory: InputScreenViewModelFactory
8288

89+
@Inject
90+
lateinit var appTheme: AppTheme
91+
8392
private val viewModel: InputScreenViewModel by lazy {
8493
val params = requireActivity().intent.getActivityParams(InputScreenActivityParams::class.java)
8594
val currentOmnibarText = params?.query ?: ""
@@ -91,11 +100,23 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
91100

92101
private val pageChangeCallback = object : OnPageChangeCallback() {
93102
override fun onPageSelected(position: Int) {
103+
viewModel.onPageSelected(position)
94104
binding.inputModeWidget.selectTab(position)
95105
}
106+
107+
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
108+
viewModel.onPageScrolled(position, positionOffset)
109+
}
110+
111+
override fun onPageScrollStateChanged(state: Int) {
112+
if (state == ViewPager2.SCROLL_STATE_IDLE) {
113+
viewModel.onScrollStateIdle()
114+
}
115+
}
96116
}
97117

98118
private lateinit var pagerAdapter: InputScreenPagerAdapter
119+
private var logoAnimator: ValueAnimator? = null
99120

100121
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
101122
super.onViewCreated(view, savedInstanceState)
@@ -109,6 +130,7 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
109130
configureOmnibar()
110131
configureVoice()
111132
configureObservers()
133+
configureLogoAnimation()
112134

113135
binding.inputModeWidget.init()
114136

@@ -168,11 +190,15 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
168190
}.launchIn(lifecycleScope)
169191

170192
viewModel.visibilityState.onEach {
171-
binding.ddgLogo.isVisible = if (binding.viewPager.currentItem == 0) {
193+
val isSearchMode = binding.viewPager.currentItem == 0
194+
binding.ddgLogoContainer.isVisible = if (isSearchMode) {
172195
it.showSearchLogo
173196
} else {
174197
it.showChatLogo
175198
}
199+
200+
binding.ddgLogo.progress = if (isSearchMode) 0f else 1f
201+
176202
binding.actionNewLine.isVisible = it.newLineButtonVisible
177203
}.launchIn(lifecycleScope)
178204
}
@@ -190,6 +216,9 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
190216
is SubmitChat -> submitChatQuery(command.query)
191217
is ShowKeyboard -> showKeyboard(binding.inputModeWidget.inputField)
192218
is HideKeyboard -> hideKeyboard(binding.inputModeWidget.inputField)
219+
is SetInputModeWidgetScrollPosition -> binding.inputModeWidget.setScrollPosition(command.position, command.offset)
220+
is SetLogoProgress -> setLogoProgress(command.targetProgress)
221+
is AnimateLogoToProgress -> animateLogoToProgress(command.targetProgress)
193222
}
194223
}
195224

@@ -213,23 +242,19 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
213242
binding.viewPager.setCurrentItem(0, true)
214243
viewModel.onSearchSelected()
215244
viewModel.onSearchInputTextChanged(binding.inputModeWidget.text)
216-
binding.ddgLogo.apply {
217-
setImageResource(com.duckduckgo.mobile.android.R.drawable.logo_full)
218-
isVisible = viewModel.visibilityState.value.showSearchLogo
219-
}
245+
binding.ddgLogoContainer.isVisible = viewModel.visibilityState.value.showSearchLogo
220246
}
221247
onChatSelected = {
222248
binding.viewPager.setCurrentItem(1, true)
223249
viewModel.onChatSelected()
224250
viewModel.onChatInputTextChanged(binding.inputModeWidget.text)
225-
binding.ddgLogo.apply {
226-
setImageResource(R.drawable.logo_full_ai)
251+
binding.ddgLogoContainer.apply {
227252
val showChatLogo = viewModel.visibilityState.value.showChatLogo
228253
val showSearchLogo = viewModel.visibilityState.value.showSearchLogo
229254
isVisible = showChatLogo
230255
if (showChatLogo && !showSearchLogo) {
231256
alpha = 0f
232-
animate().alpha(1f).setDuration(200L).start()
257+
animate().alpha(1f).setDuration(LOGO_FADE_DURATION).start()
233258
}
234259
}
235260
}
@@ -248,6 +273,9 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
248273
onInputFieldClicked = {
249274
viewModel.onInputFieldTouched()
250275
}
276+
onTabTapped = { index ->
277+
viewModel.onTabTapped(index)
278+
}
251279
}
252280

253281
private fun submitChatQuery(query: String) {
@@ -282,12 +310,41 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
282310
}.launchIn(lifecycleScope)
283311
}
284312

313+
private fun configureLogoAnimation() = with(binding.ddgLogo) {
314+
setMinAndMaxFrame(0, LOGO_MAX_FRAME)
315+
setAnimation(
316+
if (appTheme.isLightModeEnabled()) {
317+
R.raw.duckduckgo_ai_transition_light
318+
} else {
319+
R.raw.duckduckgo_ai_transition_dark
320+
},
321+
)
322+
}
323+
324+
private fun setLogoProgress(targetProgress: Float) {
325+
binding.ddgLogo.progress = targetProgress
326+
}
327+
328+
private fun animateLogoToProgress(targetProgress: Float) {
329+
logoAnimator?.cancel()
330+
binding.ddgLogo.apply {
331+
logoAnimator = ValueAnimator.ofFloat(progress, targetProgress).apply {
332+
duration = LOGO_ANIMATION_DURATION
333+
addUpdateListener { progress = it.animatedValue as Float }
334+
start()
335+
}
336+
}
337+
}
338+
285339
private fun exitInputScreen() {
286340
hideKeyboard(binding.inputModeWidget.inputField)
287341
requireActivity().supportFinishAfterTransition()
288342
}
289343

290344
override fun onDestroyView() {
345+
logoAnimator?.cancel()
346+
logoAnimator = null
347+
binding.ddgLogo.clearAnimation()
291348
binding.viewPager.unregisterOnPageChangeCallback(pageChangeCallback)
292349
super.onDestroyView()
293350
}
@@ -296,4 +353,10 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
296353
super.onResume()
297354
viewModel.onActivityResume()
298355
}
356+
357+
companion object {
358+
const val LOGO_ANIMATION_DURATION = 350L
359+
const val LOGO_MAX_FRAME = 15
360+
const val LOGO_FADE_DURATION = 200L
361+
}
299362
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/command/Command.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ sealed class Command {
2424
data class SubmitChat(val query: String) : Command()
2525
data object ShowKeyboard : Command()
2626
data object HideKeyboard : Command()
27+
data class SetInputModeWidgetScrollPosition(val position: Int, val offset: Float) : Command()
28+
data class SetLogoProgress(val targetProgress: Float) : Command()
29+
data class AnimateLogoToProgress(val targetProgress: Float) : Command()
2730
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/view/InputModeWidget.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class InputModeWidget @JvmOverloads constructor(
8282
var onChatTextChanged: ((String) -> Unit)? = null
8383
var onInputFieldClicked: (() -> Unit)? = null
8484

85+
var onTabTapped: ((index: Int) -> Unit)? = null
86+
8587
var text: String
8688
get() = inputField.text.toString()
8789
set(value) {
@@ -153,6 +155,22 @@ class InputModeWidget @JvmOverloads constructor(
153155
inputField.setOnClickListener {
154156
onInputFieldClicked?.invoke()
155157
}
158+
addTabClickListeners()
159+
}
160+
161+
private fun addTabClickListeners() {
162+
val tabStrip = inputModeSwitch.getChildAt(0) as? ViewGroup ?: return
163+
164+
repeat(inputModeSwitch.tabCount) { index ->
165+
inputModeSwitch.getTabAt(index)?.let { tab ->
166+
tabStrip.getChildAt(index)?.setOnClickListener {
167+
onTabTapped?.invoke(index)
168+
if (inputModeSwitch.selectedTabPosition != index) {
169+
tab.select()
170+
}
171+
}
172+
}
173+
}
156174
}
157175

158176
private fun configureInputBehavior() = with(inputField) {
@@ -285,6 +303,10 @@ class InputModeWidget @JvmOverloads constructor(
285303
}
286304
}
287305

306+
fun setScrollPosition(position: Int, positionOffset: Float) {
307+
inputModeSwitch.setScrollPosition(position, positionOffset, false)
308+
}
309+
288310
private fun fade(
289311
view: View,
290312
visible: Boolean,

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/viewmodel/InputScreenViewModel.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,13 @@ class InputScreenViewModel @AssistedInject constructor(
124124
) : ViewModel() {
125125

126126
private var hasUserSeenHistoryIAM = false
127+
private var isTapTransition = false
127128

128129
private val newTabPageHasContent = MutableStateFlow(false)
129130
private val voiceServiceAvailable = MutableStateFlow(voiceSearchAvailability.isVoiceSearchAvailable)
130131
private val voiceInputAllowed = MutableStateFlow(true)
131132
private var userSelectedMode: UserSelectedMode = NONE
133+
private var currentPagePosition: Int = 0
132134
private val _visibilityState = MutableStateFlow(
133135
InputScreenVisibilityState(
134136
voiceInputButtonVisible = voiceServiceAvailable.value && voiceInputAllowed.value,
@@ -445,6 +447,44 @@ class InputScreenViewModel @AssistedInject constructor(
445447
userSelectedMode = SEARCH
446448
}
447449

450+
fun onPageScrolled(position: Int, positionOffset: Float) {
451+
if (!isTapTransition) {
452+
val logoProgress = calculateLogoProgress(position, positionOffset)
453+
command.value = Command.SetLogoProgress(logoProgress)
454+
val widgetOffset = calculateInputModeWidgetScrollPosition(positionOffset)
455+
command.value = Command.SetInputModeWidgetScrollPosition(position, widgetOffset)
456+
}
457+
}
458+
459+
private fun calculateLogoProgress(position: Int, positionOffset: Float): Float {
460+
if (newTabPageHasContent.value) return 1f
461+
return if (position == 0) positionOffset else 1f - positionOffset
462+
}
463+
464+
private fun calculateInputModeWidgetScrollPosition(positionOffset: Float): Float {
465+
return when {
466+
positionOffset <= 0.5f -> positionOffset * positionOffset * 2f
467+
else -> 1f - (1f - positionOffset) * (1f - positionOffset) * 2f
468+
}
469+
}
470+
471+
fun onTabTapped(index: Int) {
472+
if (currentPagePosition != index) {
473+
isTapTransition = true
474+
if (!newTabPageHasContent.value) {
475+
command.value = Command.AnimateLogoToProgress(index.toFloat())
476+
}
477+
}
478+
}
479+
480+
fun onPageSelected(position: Int) {
481+
currentPagePosition = position
482+
}
483+
484+
fun onScrollStateIdle() {
485+
isTapTransition = false
486+
}
487+
448488
fun onSendButtonClicked() {
449489
val pixelParams = inputScreenPixelsModeParam(isSearchMode = userSelectedMode == SEARCH)
450490
pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_FLOATING_SUBMIT_PRESSED, parameters = pixelParams)

duckchat/duckchat-impl/src/main/res/layout/fragment_input_screen.xml

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,27 @@
3030
app:layout_constraintStart_toStartOf="parent"
3131
app:layout_constraintTop_toTopOf="parent" />
3232

33-
<ImageView
34-
android:id="@+id/ddgLogo"
35-
android:layout_width="@dimen/ntpDaxLogoIconWidth"
33+
<FrameLayout
34+
android:id="@+id/ddgLogoContainer"
35+
android:layout_width="180dp"
3636
android:layout_height="wrap_content"
3737
android:layout_marginTop="@dimen/homeTabDdgLogoTopMargin"
3838
android:paddingTop="@dimen/inputScreenContentTopOffset"
39-
android:adjustViewBounds="true"
40-
android:maxWidth="180dp"
41-
android:maxHeight="180dp"
4239
app:layout_constraintEnd_toEndOf="parent"
4340
app:layout_constraintStart_toStartOf="parent"
44-
app:layout_constraintTop_toBottomOf="@id/inputModeWidget"
45-
app:srcCompat="@drawable/logo_full" />
41+
app:layout_constraintTop_toBottomOf="@id/inputModeWidget">
42+
43+
<com.airbnb.lottie.LottieAnimationView
44+
android:id="@+id/ddgLogo"
45+
android:layout_width="match_parent"
46+
android:layout_height="wrap_content"
47+
android:adjustViewBounds="true"
48+
android:maxWidth="180dp"
49+
android:maxHeight="180dp"
50+
app:lottie_autoPlay="false"
51+
app:lottie_loop="false" />
52+
53+
</FrameLayout>
4654

4755
<androidx.viewpager2.widget.ViewPager2
4856
android:id="@+id/viewPager"

duckchat/duckchat-impl/src/main/res/raw/duckduckgo_ai_transition_dark.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

duckchat/duckchat-impl/src/main/res/raw/duckduckgo_ai_transition_light.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

duckchat/duckchat-impl/src/main/res/values/styles.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<item name="android:background">@drawable/tab_layout_background</item>
2020
<item name="tabIndicatorGravity">center</item>
2121
<item name="tabIndicatorColor">?attr/daxColorInputModeIndicator</item>
22-
<item name="tabIndicatorAnimationMode">elastic</item>
22+
<item name="tabIndicatorAnimationMode">linear</item>
2323
<item name="tabIndicatorFullWidth">true</item>
2424
<item name="tabSelectedTextColor">?attr/daxColorPrimaryText</item>
2525
<item name="tabTextColor">?attr/daxColorPrimaryText</item>

0 commit comments

Comments
 (0)