Skip to content
This repository was archived by the owner on Dec 9, 2024. It is now read-only.

Commit 214fed3

Browse files
authored
Merge pull request #3 from android/oscillation
List > Oscillation
2 parents 32ee2df + f86f535 commit 214fed3

File tree

9 files changed

+410
-0
lines changed

9 files changed

+410
-0
lines changed

Motion/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ package.
2424
- [Layout > FAB transformation](app/src/main/java/com/example/android/motion/demo/fabtransformation)
2525
- [List > Reorder](app/src/main/java/com/example/android/motion/demo/reorder)
2626
- [List > Stagger](app/src/main/java/com/example/android/motion/demo/stagger)
27+
- [List > Oscillation](app/src/main/java/com/example/android/motion/demo/oscillation)
2728
- [Navigation > Shared element](app/src/main/java/com/example/android/motion/demo/sharedelement)
2829
- [Navigation > Fade through](app/src/main/java/com/example/android/motion/demo/navfadethrough)

Motion/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ dependencies {
5656
implementation 'androidx.fragment:fragment-ktx:1.2.0-alpha02'
5757
implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
5858
implementation 'androidx.transition:transition:1.2.0-beta01'
59+
implementation 'androidx.dynamicanimation:dynamicanimation:1.1.0-alpha02'
5960
implementation 'androidx.recyclerview:recyclerview:1.1.0-beta02'
6061

6162
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta2'

Motion/app/src/main/AndroidManifest.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@
117117
android:resource="@array/stagger_apis" />
118118
</activity>
119119

120+
<activity
121+
android:name=".demo.oscillation.OscillationActivity"
122+
android:label="@string/oscillation_label"
123+
android:theme="@style/Theme.Motion.CardUi">
124+
<intent-filter>
125+
<action android:name="android.intent.action.MAIN" />
126+
<category android:name="com.example.android.motion.intent.category.DEMO" />
127+
</intent-filter>
128+
129+
<meta-data
130+
android:name="com.example.android.motion.demo.DESCRIPTION"
131+
android:value="@string/oscillation_description" />
132+
<meta-data
133+
android:name="com.example.android.motion.demo.APIS"
134+
android:resource="@array/oscillation_apis" />
135+
</activity>
136+
120137
<activity
121138
android:name=".demo.sharedelement.SharedElementActivity"
122139
android:label="@string/shared_element_label"
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
* Copyright 2019 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.android.motion.demo.oscillation
18+
19+
import android.view.LayoutInflater
20+
import android.view.ViewGroup
21+
import android.widget.EdgeEffect
22+
import android.widget.ImageView
23+
import android.widget.TextView
24+
import androidx.core.view.doOnLayout
25+
import androidx.core.view.doOnNextLayout
26+
import androidx.dynamicanimation.animation.SpringAnimation
27+
import androidx.dynamicanimation.animation.SpringForce
28+
import androidx.recyclerview.widget.ListAdapter
29+
import androidx.recyclerview.widget.RecyclerView
30+
import com.bumptech.glide.Glide
31+
import com.example.android.motion.R
32+
import com.example.android.motion.model.Cheese
33+
34+
internal class CheeseAdapter : ListAdapter<Cheese, CheeseViewHolder>(Cheese.DIFF_CALLBACK) {
35+
36+
/**
37+
* A [RecyclerView.OnScrollListener] to be set to the RecyclerView. This tilts the visible
38+
* items while the list is scrolled.
39+
*
40+
* @see RecyclerView.addOnScrollListener
41+
*/
42+
val onScrollListener = object : RecyclerView.OnScrollListener() {
43+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
44+
recyclerView.forEachVisibleHolder { holder: CheeseViewHolder ->
45+
holder.rotation
46+
// Update the velocity.
47+
// The velocity is calculated by the horizontal scroll offset.
48+
.setStartVelocity(holder.currentVelocity - dx * SCROLL_ROTATION_MAGNITUDE)
49+
// Start the animation. This does nothing if the animation is already running.
50+
.start()
51+
}
52+
}
53+
}
54+
55+
/**
56+
* A [RecyclerView.EdgeEffectFactory] to be set to the RecyclerView. This adds bounce effect
57+
* when the list is over-scrolled.
58+
*
59+
* @see RecyclerView.setEdgeEffectFactory
60+
*/
61+
val edgeEffectFactory = object : RecyclerView.EdgeEffectFactory() {
62+
override fun createEdgeEffect(recyclerView: RecyclerView, direction: Int): EdgeEffect {
63+
return object : EdgeEffect(recyclerView.context) {
64+
65+
override fun onPull(deltaDistance: Float) {
66+
super.onPull(deltaDistance)
67+
handlePull(deltaDistance)
68+
}
69+
70+
override fun onPull(deltaDistance: Float, displacement: Float) {
71+
super.onPull(deltaDistance, displacement)
72+
handlePull(deltaDistance)
73+
}
74+
75+
private fun handlePull(deltaDistance: Float) {
76+
// This is called on every touch event while the list is scrolled with a finger.
77+
// We simply update the view properties without animation.
78+
val sign = if (direction == DIRECTION_RIGHT) -1 else 1
79+
val rotationDelta = sign * deltaDistance * OVERSCROLL_ROTATION_MAGNITUDE
80+
val translationXDelta =
81+
sign * recyclerView.width * deltaDistance * OVERSCROLL_TRANSLATION_MAGNITUDE
82+
recyclerView.forEachVisibleHolder { holder: CheeseViewHolder ->
83+
holder.rotation.cancel()
84+
holder.translationX.cancel()
85+
holder.itemView.rotation += rotationDelta
86+
holder.itemView.translationX += translationXDelta
87+
}
88+
}
89+
90+
override fun onRelease() {
91+
super.onRelease()
92+
// The finger is lifted. This is when we should start the animations to bring
93+
// the view property values back to their resting states.
94+
recyclerView.forEachVisibleHolder { holder: CheeseViewHolder ->
95+
holder.rotation.start()
96+
holder.translationX.start()
97+
}
98+
}
99+
100+
override fun onAbsorb(velocity: Int) {
101+
super.onAbsorb(velocity)
102+
val sign = if (direction == DIRECTION_RIGHT) -1 else 1
103+
// The list has reached the edge on fling.
104+
val translationVelocity = sign * velocity * FLING_TRANSLATION_MAGNITUDE
105+
recyclerView.forEachVisibleHolder { holder: CheeseViewHolder ->
106+
holder.translationX
107+
.setStartVelocity(translationVelocity)
108+
.start()
109+
}
110+
}
111+
}
112+
}
113+
}
114+
115+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheeseViewHolder {
116+
return CheeseViewHolder(parent).apply {
117+
// The rotation pivot should be at the center of the top edge.
118+
itemView.doOnLayout { v -> v.pivotX = v.width / 2f }
119+
itemView.pivotY = 0f
120+
}
121+
}
122+
123+
override fun onBindViewHolder(holder: CheeseViewHolder, position: Int) {
124+
val cheese = getItem(position)
125+
Glide.with(holder.image).load(cheese.image).into(holder.image)
126+
holder.name.text = cheese.name
127+
}
128+
}
129+
130+
131+
internal class CheeseViewHolder(
132+
val parent: ViewGroup
133+
) : RecyclerView.ViewHolder(
134+
LayoutInflater.from(parent.context)
135+
.inflate(R.layout.cheese_board_item, parent, false)
136+
) {
137+
val image: ImageView = itemView.findViewById(R.id.image)
138+
val name: TextView = itemView.findViewById(R.id.name)
139+
140+
var currentVelocity = 0f
141+
142+
/**
143+
* A [SpringAnimation] for this RecyclerView item. This animation rotates the view with a bouncy
144+
* spring configuration, resulting in the oscillation effect.
145+
*
146+
* The animation is started in [CheeseAdapter.onScrollListener].
147+
*/
148+
val rotation: SpringAnimation = SpringAnimation(itemView, SpringAnimation.ROTATION)
149+
.setSpring(
150+
SpringForce()
151+
.setFinalPosition(0f)
152+
.setDampingRatio(SpringForce.DAMPING_RATIO_HIGH_BOUNCY)
153+
.setStiffness(SpringForce.STIFFNESS_LOW)
154+
)
155+
.addUpdateListener { _, _, velocity ->
156+
currentVelocity = velocity
157+
}
158+
159+
/**
160+
* A [SpringAnimation] for this RecyclerView item. This animation is used to bring the item back
161+
* after the over-scroll effect.
162+
*/
163+
val translationX: SpringAnimation = SpringAnimation(itemView, SpringAnimation.TRANSLATION_X)
164+
.setSpring(
165+
SpringForce()
166+
.setFinalPosition(0f)
167+
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
168+
.setStiffness(SpringForce.STIFFNESS_LOW)
169+
)
170+
}
171+
172+
/**
173+
* Runs [action] on every visible [RecyclerView.ViewHolder] in this [RecyclerView].
174+
*/
175+
private inline fun <reified T : RecyclerView.ViewHolder> RecyclerView.forEachVisibleHolder(
176+
action: (T) -> Unit
177+
) {
178+
for (i in 0 until childCount) {
179+
action(getChildViewHolder(getChildAt(i)) as T)
180+
}
181+
}
182+
183+
// The constants below are used to calculate the animation magnitude from values taken from UI
184+
// events. Their values are determined empirically and can be modified to change the animation
185+
// flavor.
186+
187+
/** The magnitude of rotation while the list is scrolled. */
188+
private const val SCROLL_ROTATION_MAGNITUDE = 0.25f
189+
/** The magnitude of rotation while the list is over-scrolled. */
190+
private const val OVERSCROLL_ROTATION_MAGNITUDE = -10
191+
/** The magnitude of translation distance while the list is over-scrolled. */
192+
private const val OVERSCROLL_TRANSLATION_MAGNITUDE = 0.2f
193+
/** The magnitude of translation distance when the list reaches the edge on fling. */
194+
private const val FLING_TRANSLATION_MAGNITUDE = 0.5f
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2019 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.android.motion.demo.oscillation
18+
19+
import android.os.Bundle
20+
import androidx.activity.viewModels
21+
import androidx.appcompat.app.AppCompatActivity
22+
import androidx.appcompat.widget.Toolbar
23+
import androidx.lifecycle.observe
24+
import androidx.recyclerview.widget.RecyclerView
25+
import com.example.android.motion.R
26+
import com.example.android.motion.ui.EdgeToEdge
27+
28+
class OscillationActivity : AppCompatActivity() {
29+
30+
private val viewModel: OscillationViewModel by viewModels()
31+
32+
override fun onCreate(savedInstanceState: Bundle?) {
33+
super.onCreate(savedInstanceState)
34+
setContentView(R.layout.oscillation_activity)
35+
36+
val toolbar: Toolbar = findViewById(R.id.toolbar)
37+
val list: RecyclerView = findViewById(R.id.list)
38+
setSupportActionBar(toolbar)
39+
40+
EdgeToEdge.setUpRoot(findViewById(R.id.root))
41+
EdgeToEdge.setUpAppBar(findViewById(R.id.app_bar), toolbar)
42+
EdgeToEdge.setUpScrollingContent(list)
43+
44+
val adapter = CheeseAdapter()
45+
list.adapter = adapter
46+
// The adapter knows how to animate its items while the list is scrolled.
47+
list.addOnScrollListener(adapter.onScrollListener)
48+
list.edgeEffectFactory = adapter.edgeEffectFactory
49+
50+
viewModel.cheeses.observe(this) { cheeses ->
51+
adapter.submitList(cheeses)
52+
}
53+
}
54+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2019 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.android.motion.demo.oscillation
18+
19+
import androidx.lifecycle.LiveData
20+
import androidx.lifecycle.MutableLiveData
21+
import androidx.lifecycle.ViewModel
22+
import com.example.android.motion.model.Cheese
23+
24+
class OscillationViewModel : ViewModel() {
25+
26+
val cheeses: LiveData<List<Cheese>> = MutableLiveData(Cheese.ALL.filter {
27+
it.name.length < 10
28+
}.shuffled().take(15))
29+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright 2019 The Android Open Source Project
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+
~ http://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+
<com.google.android.material.card.MaterialCardView
18+
xmlns:android="http://schemas.android.com/apk/res/android"
19+
xmlns:app="http://schemas.android.com/apk/res-auto"
20+
xmlns:tools="http://schemas.android.com/tools"
21+
android:layout_width="wrap_content"
22+
android:layout_height="wrap_content"
23+
android:layout_margin="@dimen/spacing_medium"
24+
app:cardCornerRadius="1dp">
25+
26+
<LinearLayout
27+
android:layout_width="wrap_content"
28+
android:layout_height="wrap_content"
29+
android:orientation="horizontal">
30+
31+
<ImageView
32+
android:id="@+id/image"
33+
android:layout_width="64dp"
34+
android:layout_height="64dp"
35+
android:contentDescription="@null"
36+
android:scaleType="centerCrop"
37+
tools:src="@drawable/cheese_1" />
38+
39+
<TextView
40+
android:id="@+id/name"
41+
android:layout_width="0dp"
42+
android:layout_height="match_parent"
43+
android:layout_margin="@dimen/spacing_medium"
44+
android:layout_weight="1"
45+
android:textAppearance="?attr/textAppearanceHeadline6"
46+
tools:text="Cheese" />
47+
48+
</LinearLayout>
49+
50+
</com.google.android.material.card.MaterialCardView>

0 commit comments

Comments
 (0)