Skip to content

Commit

Permalink
Fix oppia#4445: Add scaling animation for Continue button (oppia#4568)
Browse files Browse the repository at this point in the history
<!-- READ ME FIRST: Please fill in the explanation section below and
check off every point from the Essential Checklist! -->
## Explanation
<!--
- Explain what your PR does. If this PR fixes an existing bug, please
include
- "Fixes #bugnum:" in the explanation so that GitHub can auto-close the
issue
  - when this PR is merged.
  -->
Fixes oppia#4445

This PR is part of the GSoC project: Interactive Onboarding Flow which
fixes oppia#4445. It does so by creating a new custom view which connects to
the `ContinueInteractionItemViewModel`, which receives a flag to start
animating from the explorationProgressController. We wait for 45 seconds
when the first card of a lesson is opened by the user, and then start
the animation for the continue button.

## Essential Checklist
<!-- Please tick the relevant boxes by putting an "x" in them. -->
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).

## For UI-specific PRs only
<!-- Delete these section if this PR does not include UI-related
changes. -->
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-(A11y)-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing


https://user-images.githubusercontent.com/64526117/202579860-643e2d20-b969-438d-be86-f690630b8278.mp4

Co-authored-by: Ben Henning <[email protected]>
Co-authored-by: Ben Henning <[email protected]>
  • Loading branch information
3 people authored Nov 19, 2022
1 parent 7cb38ff commit 6f26b9e
Show file tree
Hide file tree
Showing 47 changed files with 809 additions and 76 deletions.
2 changes: 2 additions & 0 deletions app/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ VIEW_MODELS = [

# keep sorted
VIEWS_WITH_RESOURCE_IMPORTS = [
"src/main/java/org/oppia/android/app/customview/ContinueButtonView.kt",
"src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt",
"src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt",
"src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt",
Expand Down Expand Up @@ -630,6 +631,7 @@ kt_android_library(
":snap_helper",
":view_models",
"//app/src/main/java/org/oppia/android/app/shim:view_binding_shim",
"//app/src/main/java/org/oppia/android/app/utility/lifecycle:lifecycle_safe_timer_factory",
"//app/src/main/java/org/oppia/android/app/view:view_component_factory",
"//app/src/main/java/org/oppia/android/app/view:view_scope",
"//third_party:androidx_appcompat_appcompat",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package org.oppia.android.app.customview

import android.content.Context
import android.util.AttributeSet
import android.view.animation.AnimationUtils
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LiveData
import org.oppia.android.R
import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory
import org.oppia.android.app.view.ViewComponentFactory
import org.oppia.android.app.view.ViewComponentImpl
import org.oppia.android.domain.oppialogger.OppiaLogger
import org.oppia.android.util.platformparameter.EnableContinueButtonAnimation
import org.oppia.android.util.platformparameter.PlatformParameterValue
import org.oppia.android.util.system.OppiaClock
import javax.inject.Inject

/** A custom [AppCompatButton] used to show continue button animations. */
class ContinueButtonView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.style.StateButtonActive
) : androidx.appcompat.widget.AppCompatButton(context, attrs, defStyleAttr) {

@field:[Inject EnableContinueButtonAnimation]
lateinit var enableContinueButtonAnimation: PlatformParameterValue<Boolean>
@Inject lateinit var fragment: Fragment
@Inject lateinit var oppiaClock: OppiaClock
@Inject lateinit var lifecycleSafeTimerFactory: LifecycleSafeTimerFactory
@Inject lateinit var oppiaLogger: OppiaLogger

private var shouldAnimateContinueButtonLateinit: Boolean? = null
private val shouldAnimateContinueButton: Boolean
get() = checkNotNull(shouldAnimateContinueButtonLateinit) {
"Expected shouldAnimateContinueButtonLateinit to be initialized by this point."
}

private var continueButtonAnimationTimestampMsLateinit: Long? = null
private val continueButtonAnimationTimestampMs: Long
get() = checkNotNull(continueButtonAnimationTimestampMsLateinit) {
"Expected continueButtonAnimationTimestampMsLateinit to be initialized by this point."
}

private val hasAnimationTimerFinished: Boolean
get() = continueButtonAnimationTimestampMs < oppiaClock.getCurrentTimeMs()

private var animationStartTimer: LiveData<Any>? = null
private var currentAnimationReuseCount = 0

override fun onAttachedToWindow() {
super.onAttachedToWindow()
val viewComponentFactory =
FragmentManager.findFragment<Fragment>(this) as ViewComponentFactory
val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl
viewComponent.inject(this)
maybeInitializeAnimation()
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()

// Make sure state can't leak across rebinding boundaries (since this view may be reused).
cancelOngoingTimer()
}

/** Sets whether the view should animate to catch a user's attention. */
fun setShouldAnimateContinueButton(shouldAnimateContinueButton: Boolean) {
shouldAnimateContinueButtonLateinit = shouldAnimateContinueButton
maybeInitializeAnimation()
}

/**
* Sets when, in clock time, the animation controlled by [setShouldAnimateContinueButton] should
* play.
*/
fun setContinueButtonAnimationTimestampMs(continueButtonAnimationTimestampMs: Long) {
continueButtonAnimationTimestampMsLateinit = continueButtonAnimationTimestampMs
maybeInitializeAnimation()
}

private fun maybeInitializeAnimation() {
if (::oppiaClock.isInitialized &&
shouldAnimateContinueButtonLateinit != null &&
continueButtonAnimationTimestampMsLateinit != null
) {
when {
!shouldAnimateContinueButton -> clearAnimation()
hasAnimationTimerFinished -> startAnimating()
else -> {
val timeLeftToAnimate = continueButtonAnimationTimestampMs - oppiaClock.getCurrentTimeMs()
startAnimatingWithDelay(delayMs = timeLeftToAnimate)
}
}
}
}

private fun startAnimatingWithDelay(delayMs: Long) {
cancelOngoingTimer()
val sequenceNumber = currentAnimationReuseCount
lifecycleSafeTimerFactory.createTimer(delayMs).observe(fragment) {
// Only play the animation if it's still valid to do so (since the view may have been recycled
// for a new context that may not want the animation to play).
if (sequenceNumber == currentAnimationReuseCount) {
startAnimating()
}
}
}

private fun cancelOngoingTimer() {
currentAnimationReuseCount++
animationStartTimer?.let {
it.removeObservers(fragment)
animationStartTimer = null
}
}

private fun startAnimating() {
val animation = AnimationUtils.loadAnimation(context, R.anim.scale_button_size)
if (enableContinueButtonAnimation.value) {
startAnimation(animation)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ import org.oppia.android.app.player.state.ConfettiConfig.MINI_CONFETTI_BURST
import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListener
import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener
import org.oppia.android.app.topic.conceptcard.ConceptCardFragment.Companion.CONCEPT_CARD_DIALOG_FRAGMENT_TAG
import org.oppia.android.app.utility.LifecycleSafeTimerFactory
import org.oppia.android.app.utility.SplitScreenManager
import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory
import org.oppia.android.app.viewmodel.ViewModelProvider
import org.oppia.android.databinding.StateFragmentBinding
import org.oppia.android.domain.exploration.ExplorationProgressController
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import org.oppia.android.app.recyclerview.BindableAdapter
import org.oppia.android.app.topic.conceptcard.ConceptCardFragment
import org.oppia.android.app.topic.conceptcard.ConceptCardFragment.Companion.CONCEPT_CARD_DIALOG_FRAGMENT_TAG
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.app.utility.LifecycleSafeTimerFactory
import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory
import org.oppia.android.databinding.ContentItemBinding
import org.oppia.android.databinding.ContinueInteractionItemBinding
import org.oppia.android.databinding.ContinueNavigationButtonItemBinding
Expand Down Expand Up @@ -226,12 +226,18 @@ class StatePlayerRecyclerViewAssembler private constructor(
if (playerFeatureSet.interactionSupport) {
val interactionItemList =
if (isSplitView) extraInteractionPendingItemList else conversationPendingItemList
val timeToStartNoticeAnimationMs = if (interaction.id == "Continue") {
ephemeralState.continueButtonAnimationTimestampMs.takeIf {
ephemeralState.showContinueButtonAnimation
}
} else null
addInteractionForPendingState(
interactionItemList,
interaction,
hasPreviousState,
gcsEntityId,
ephemeralState.writtenTranslationContext
ephemeralState.writtenTranslationContext,
timeToStartNoticeAnimationMs
)
}
} else if (ephemeralState.stateTypeCase == StateTypeCase.COMPLETED_STATE) {
Expand Down Expand Up @@ -295,7 +301,9 @@ class StatePlayerRecyclerViewAssembler private constructor(
hasPreviousState,
canContinueToNextState,
hasGeneralContinueButton,
isTerminalState
isTerminalState,
shouldAnimateContinueButton = ephemeralState.showContinueButtonAnimation,
continueButtonAnimationTimestampMs = ephemeralState.continueButtonAnimationTimestampMs
)
return Pair(conversationPendingItemList, extraInteractionPendingItemList)
}
Expand All @@ -305,7 +313,8 @@ class StatePlayerRecyclerViewAssembler private constructor(
interaction: Interaction,
hasPreviousButton: Boolean,
gcsEntityId: String,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
) {
val interactionViewModelFactory = interactionViewModelFactoryMap.getValue(interaction.id)
pendingItemList += interactionViewModelFactory.create(
Expand All @@ -316,7 +325,8 @@ class StatePlayerRecyclerViewAssembler private constructor(
fragment as InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton,
isSplitView.get()!!,
writtenTranslationContext
writtenTranslationContext,
timeToStartNoticeAnimationMs
)
}

Expand Down Expand Up @@ -592,15 +602,19 @@ class StatePlayerRecyclerViewAssembler private constructor(
hasPreviousState: Boolean,
canContinueToNextState: Boolean,
hasGeneralContinueButton: Boolean,
stateIsTerminal: Boolean
stateIsTerminal: Boolean,
shouldAnimateContinueButton: Boolean,
continueButtonAnimationTimestampMs: Long
) {
val hasPreviousButton = playerFeatureSet.backwardNavigation && hasPreviousState
when {
hasGeneralContinueButton && playerFeatureSet.forwardNavigation -> {
addContinueNavigation(
conversationPendingItemList,
extraInteractionPendingItemList,
hasPreviousButton
hasPreviousButton,
shouldAnimateContinueButton,
continueButtonAnimationTimestampMs
)
}
canContinueToNextState && playerFeatureSet.forwardNavigation -> {
Expand Down Expand Up @@ -725,7 +739,9 @@ class StatePlayerRecyclerViewAssembler private constructor(
private fun addContinueNavigation(
conversationPendingItemList: MutableList<StateItemViewModel>,
extraInteractionPendingItemList: MutableList<StateItemViewModel>,
hasPreviousButton: Boolean
hasPreviousButton: Boolean,
shouldAnimateContinueButton: Boolean,
continueButtonAnimationTimestampMs: Long
) {
val targetList =
if (isSplitView.get()!!) extraInteractionPendingItemList else conversationPendingItemList
Expand All @@ -735,7 +751,9 @@ class StatePlayerRecyclerViewAssembler private constructor(
hasConversationView,
previousNavigationButtonListener,
fragment as ContinueNavigationButtonListener,
isSplitView.get()!!
isSplitView.get()!!,
shouldAnimateContinueButton,
continueButtonAnimationTimestampMs
)
if (isSplitView.get()!!) {
// "previous button" should appear in the conversation recycler view only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ class ContinueInteractionViewModel private constructor(
val hasPreviousButton: Boolean,
val previousNavigationButtonListener: PreviousNavigationButtonListener,
val isSplitView: Boolean,
private val writtenTranslationContext: WrittenTranslationContext
private val writtenTranslationContext: WrittenTranslationContext,
val shouldAnimateContinueButton: Boolean,
val continueButtonAnimationTimestampMs: Long
) : StateItemViewModel(ViewType.CONTINUE_INTERACTION), InteractionAnswerHandler {

override fun isExplicitAnswerSubmissionRequired(): Boolean = false

override fun isAutoNavigating(): Boolean = true
Expand All @@ -46,9 +47,7 @@ class ContinueInteractionViewModel private constructor(
}

/** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */
class FactoryImpl @Inject constructor(
private val fragment: Fragment
) : InteractionItemFactory {
class FactoryImpl @Inject constructor(private val fragment: Fragment) : InteractionItemFactory {
override fun create(
entityId: String,
hasConversationView: Boolean,
Expand All @@ -57,15 +56,18 @@ class ContinueInteractionViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return ContinueInteractionViewModel(
interactionAnswerReceiver,
hasConversationView,
hasPreviousButton,
fragment as PreviousNavigationButtonListener,
isSplitView,
writtenTranslationContext
writtenTranslationContext,
shouldAnimateContinueButton = timeToStartNoticeAnimationMs != null,
continueButtonAnimationTimestampMs = timeToStartNoticeAnimationMs ?: 0L
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ class ContinueNavigationButtonViewModel(
val hasConversationView: Boolean,
val previousNavigationButtonListener: PreviousNavigationButtonListener,
val continueNavigationButtonListener: ContinueNavigationButtonListener,
val isSplitView: Boolean
val isSplitView: Boolean,
val shouldAnimateContinueButton: Boolean,
val continueButtonAnimationTimestampMs: Long
) : StateItemViewModel(ViewType.CONTINUE_NAVIGATION_BUTTON)
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ class DragAndDropSortInteractionViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return DragAndDropSortInteractionViewModel(
entityId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ class FractionInteractionViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return FractionInteractionViewModel(
interaction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ class ImageRegionSelectionInteractionViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return ImageRegionSelectionInteractionViewModel(
entityId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ class MathExpressionInteractionsViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return MathExpressionInteractionsViewModel(
interaction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ class NumericInputViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return NumericInputViewModel(
hasConversationView,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ class RatioExpressionInputInteractionViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return RatioExpressionInputInteractionViewModel(
interaction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ class SelectionInteractionViewModel private constructor(
answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver,
hasPreviousButton: Boolean,
isSplitView: Boolean,
writtenTranslationContext: WrittenTranslationContext
writtenTranslationContext: WrittenTranslationContext,
timeToStartNoticeAnimationMs: Long?
): StateItemViewModel {
return SelectionInteractionViewModel(
entityId,
Expand Down
Loading

0 comments on commit 6f26b9e

Please sign in to comment.