Merge changes from topic "am-989a33ff-7fb5-484a-ad22-f8209a1ea3dc"
* changes:
[automerger] Use explicit function instead of lambda. am: 461cc7c0bd
Use explicit function instead of lambda.
diff --git a/persistence/db/src/main/java/android/arch/persistence/db/SimpleSQLiteQuery.java b/persistence/db/src/main/java/android/arch/persistence/db/SimpleSQLiteQuery.java
index d16045f..13faf24 100644
--- a/persistence/db/src/main/java/android/arch/persistence/db/SimpleSQLiteQuery.java
+++ b/persistence/db/src/main/java/android/arch/persistence/db/SimpleSQLiteQuery.java
@@ -96,6 +96,8 @@
statement.bindLong(index, (Byte) arg);
} else if (arg instanceof String) {
statement.bindString(index, (String) arg);
+ } else if (arg instanceof Boolean) {
+ statement.bindLong(index, ((Boolean) arg) ? 1 : 0);
} else {
throw new IllegalArgumentException("Cannot bind " + arg + " at index " + index
+ " Supported types: null, byte[], float, double, long, int, short, byte,"
diff --git a/room/compiler/src/main/kotlin/android/arch/persistence/room/vo/Relation.kt b/room/compiler/src/main/kotlin/android/arch/persistence/room/vo/Relation.kt
index 95130e8..512770a 100644
--- a/room/compiler/src/main/kotlin/android/arch/persistence/room/vo/Relation.kt
+++ b/room/compiler/src/main/kotlin/android/arch/persistence/room/vo/Relation.kt
@@ -44,8 +44,8 @@
}
private fun createSelect(resultFields: Set<String>): String {
- return "SELECT ${resultFields.joinToString(",")}" +
+ return "SELECT ${resultFields.joinToString(",") {"`$it`"}}" +
" FROM `${entity.tableName}`" +
- " WHERE ${entityField.columnName} IN (:args)"
+ " WHERE `${entityField.columnName}` IN (:args)"
}
}
diff --git a/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/dao/UserDao.java b/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/dao/UserDao.java
index 768f64a..1db5b46 100644
--- a/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/dao/UserDao.java
+++ b/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/dao/UserDao.java
@@ -118,6 +118,10 @@
@Query("select * from user where mId = :id")
public abstract LiveData<User> liveUserById(int id);
+ @Transaction
+ @Query("select * from user where mId = :id")
+ public abstract LiveData<User> liveUserByIdInTransaction(int id);
+
@Query("select * from user where mName LIKE '%' || :name || '%' ORDER BY mId DESC")
public abstract LiveData<List<User>> liveUsersListByName(String name);
diff --git a/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/test/RelationWithReservedKeywordTest.java b/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/test/RelationWithReservedKeywordTest.java
new file mode 100644
index 0000000..70581f2
--- /dev/null
+++ b/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/test/RelationWithReservedKeywordTest.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.arch.persistence.room.integration.testapp.test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import static java.util.Collections.singletonList;
+
+import android.arch.persistence.room.ColumnInfo;
+import android.arch.persistence.room.Dao;
+import android.arch.persistence.room.Database;
+import android.arch.persistence.room.Embedded;
+import android.arch.persistence.room.Entity;
+import android.arch.persistence.room.ForeignKey;
+import android.arch.persistence.room.Index;
+import android.arch.persistence.room.Insert;
+import android.arch.persistence.room.PrimaryKey;
+import android.arch.persistence.room.Query;
+import android.arch.persistence.room.Relation;
+import android.arch.persistence.room.Room;
+import android.arch.persistence.room.RoomDatabase;
+import android.arch.persistence.room.Transaction;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+import java.util.Objects;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class RelationWithReservedKeywordTest {
+ private MyDatabase mDb;
+
+ @Before
+ public void initDb() {
+ mDb = Room.inMemoryDatabaseBuilder(
+ InstrumentationRegistry.getTargetContext(),
+ MyDatabase.class).build();
+ }
+
+ @Test
+ public void loadRelation() {
+ Category category = new Category(1, "cat1");
+ mDb.getDao().insert(category);
+ Topic topic = new Topic(2, 1, "foo");
+ mDb.getDao().insert(topic);
+ List<CategoryWithTopics> categoryWithTopics = mDb.getDao().loadAll();
+ assertThat(categoryWithTopics.size(), is(1));
+ assertThat(categoryWithTopics.get(0).category, is(category));
+ assertThat(categoryWithTopics.get(0).topics, is(singletonList(topic)));
+ }
+
+ @Entity(tableName = "categories")
+ static class Category {
+
+ @PrimaryKey(autoGenerate = true)
+ public final long id;
+
+ public final String name;
+
+ Category(long id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Category category = (Category) o;
+ return id == category.id
+ && Objects.equals(name, category.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name);
+ }
+ }
+
+ @Dao
+ interface MyDao {
+ @Transaction
+ @Query("SELECT * FROM categories")
+ List<CategoryWithTopics> loadAll();
+
+ @Insert
+ void insert(Category... categories);
+
+ @Insert
+ void insert(Topic... topics);
+ }
+
+ @Database(
+ entities = {Category.class, Topic.class},
+ version = 1,
+ exportSchema = false)
+ abstract static class MyDatabase extends RoomDatabase {
+ abstract MyDao getDao();
+ }
+
+
+ @SuppressWarnings("WeakerAccess")
+ static class CategoryWithTopics {
+ @Embedded
+ public Category category;
+
+ @Relation(
+ parentColumn = "id",
+ entityColumn = "category_id",
+ entity = Topic.class)
+ public List<Topic> topics;
+ }
+
+ @Entity(
+ tableName = "topics",
+ foreignKeys = @ForeignKey(
+ entity = Category.class,
+ parentColumns = "id",
+ childColumns = "category_id",
+ onDelete = ForeignKey.CASCADE),
+ indices = @Index("category_id"))
+ static class Topic {
+
+ @PrimaryKey(autoGenerate = true)
+ public final long id;
+
+ @ColumnInfo(name = "category_id")
+ public final long categoryId;
+
+ public final String to;
+
+ Topic(long id, long categoryId, String to) {
+ this.id = id;
+ this.categoryId = categoryId;
+ this.to = to;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Topic topic = (Topic) o;
+ return id == topic.id
+ && categoryId == topic.categoryId
+ && Objects.equals(to, topic.to);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, categoryId, to);
+ }
+ }
+}
diff --git a/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/test/WriteAheadLoggingTest.java b/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/test/WriteAheadLoggingTest.java
index 8d8e0e8..5bc01ce 100644
--- a/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/test/WriteAheadLoggingTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/android/arch/persistence/room/integration/testapp/test/WriteAheadLoggingTest.java
@@ -17,10 +17,12 @@
package android.arch.persistence.room.integration.testapp.test;
import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.hasItem;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalToIgnoringCase;
+import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.timeout;
@@ -39,6 +41,7 @@
import android.database.Cursor;
import android.support.annotation.NonNull;
import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.LargeTest;
import android.support.test.filters.MediumTest;
import android.support.test.filters.SdkSuppress;
import android.support.test.runner.AndroidJUnit4;
@@ -125,7 +128,18 @@
LiveData<User> user1 = dao.liveUserById(1);
Observer<User> observer = startObserver(user1);
dao.insert(TestUtil.createUser(1));
- verify(observer, timeout(30000).atLeastOnce())
+ verify(observer, timeout(3000).atLeastOnce())
+ .onChanged(argThat(user -> user != null && user.getId() == 1));
+ stopObserver(user1, observer);
+ }
+
+ @Test
+ public void observeLiveDataWithTransaction() {
+ UserDao dao = mDatabase.getUserDao();
+ LiveData<User> user1 = dao.liveUserByIdInTransaction(1);
+ Observer<User> observer = startObserver(user1);
+ dao.insert(TestUtil.createUser(1));
+ verify(observer, timeout(3000).atLeastOnce())
.onChanged(argThat(user -> user != null && user.getId() == 1));
stopObserver(user1, observer);
}
@@ -159,15 +173,12 @@
final UserDao dao = mDatabase.getUserDao();
final User user1 = TestUtil.createUser(1);
dao.insert(user1);
- Future<Boolean> future;
try {
mDatabase.beginTransaction();
dao.delete(user1);
ExecutorService executor = Executors.newSingleThreadExecutor();
- future = executor.submit(() -> {
- assertThat(dao.load(1), is(equalTo(user1)));
- return true;
- });
+ Future<?> future = executor.submit(() ->
+ assertThat(dao.load(1), is(equalTo(user1))));
future.get();
mDatabase.setTransactionSuccessful();
} finally {
@@ -177,21 +188,52 @@
}
@Test
+ @LargeTest
+ public void observeInvalidationInBackground() throws InterruptedException, ExecutionException {
+ final UserDao dao = mDatabase.getUserDao();
+ final User user1 = TestUtil.createUser(1);
+ final CountDownLatch observerRegistered = new CountDownLatch(1);
+ final CountDownLatch onInvalidatedCalled = new CountDownLatch(1);
+ dao.insert(user1);
+ Future future;
+ try {
+ mDatabase.beginTransaction();
+ dao.delete(user1);
+ future = Executors.newSingleThreadExecutor().submit(() -> {
+ // Adding this observer will be blocked by the surrounding transaction.
+ mDatabase.getInvalidationTracker().addObserver(
+ new InvalidationTracker.Observer("User") {
+ @Override
+ public void onInvalidated(@NonNull Set<String> tables) {
+ onInvalidatedCalled.countDown(); // This should not happen
+ }
+ });
+ observerRegistered.countDown();
+ });
+ mDatabase.setTransactionSuccessful();
+ } finally {
+ assertThat(observerRegistered.getCount(), is(1L));
+ mDatabase.endTransaction();
+ }
+ assertThat(dao.count(), is(0));
+ assertThat(observerRegistered.await(3000, TimeUnit.MILLISECONDS), is(true));
+ future.get();
+ assertThat(onInvalidatedCalled.await(500, TimeUnit.MILLISECONDS), is(false));
+ }
+
+ @Test
public void invalidation() throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
mDatabase.getInvalidationTracker().addObserver(new InvalidationTracker.Observer("User") {
@Override
public void onInvalidated(@NonNull Set<String> tables) {
+ assertThat(tables, hasSize(1));
+ assertThat(tables, hasItem("User"));
latch.countDown();
}
});
mDatabase.getUserDao().insert(TestUtil.createUser(1));
- latch.await(3000, TimeUnit.MILLISECONDS);
- for (int i = 0; i < 10; i++) {
- // This can (occasionally) detect if there is an recursive loop in InvalidationTracker
- // invalidating itself by running its refresh query in a transaction.
- assertThat(mDatabase.inTransaction(), is(false));
- }
+ assertThat(latch.await(3000, TimeUnit.MILLISECONDS), is(true));
}
private static <T> Observer<T> startObserver(LiveData<T> liveData) {
diff --git a/room/runtime/src/main/java/android/arch/persistence/room/InvalidationTracker.java b/room/runtime/src/main/java/android/arch/persistence/room/InvalidationTracker.java
index 86054ce..7bb4fb2 100644
--- a/room/runtime/src/main/java/android/arch/persistence/room/InvalidationTracker.java
+++ b/room/runtime/src/main/java/android/arch/persistence/room/InvalidationTracker.java
@@ -159,6 +159,7 @@
} finally {
database.endTransaction();
}
+ syncTriggers(database);
mCleanupStatement = database.compileStatement(CLEANUP_SQL);
mInitialized = true;
}
@@ -219,6 +220,7 @@
*
* @param observer The observer which listens the database for changes.
*/
+ @WorkerThread
public void addObserver(@NonNull Observer observer) {
final String[] tableNames = observer.mTables;
int[] tableIds = new int[tableNames.length];
@@ -240,7 +242,7 @@
currentObserver = mObserverMap.putIfAbsent(observer, wrapper);
}
if (currentObserver == null && mObservedTableTracker.onAdded(tableIds)) {
- ArchTaskExecutor.getInstance().executeOnDiskIO(mSyncTriggers);
+ syncTriggers();
}
}
@@ -265,65 +267,17 @@
* @param observer The observer to remove.
*/
@SuppressWarnings("WeakerAccess")
+ @WorkerThread
public void removeObserver(@NonNull final Observer observer) {
ObserverWrapper wrapper;
synchronized (mObserverMap) {
wrapper = mObserverMap.remove(observer);
}
if (wrapper != null && mObservedTableTracker.onRemoved(wrapper.mTableIds)) {
- ArchTaskExecutor.getInstance().executeOnDiskIO(mSyncTriggers);
+ syncTriggers();
}
}
- private Runnable mSyncTriggers = new Runnable() {
- @Override
- public void run() {
- if (mDatabase.inTransaction()) {
- // we won't run this inside another transaction.
- return;
- }
- if (!ensureInitialization()) {
- return;
- }
- try {
- // This method runs in a while loop because while changes are synced to db, another
- // runnable may be skipped. If we cause it to skip, we need to do its work.
- while (true) {
- // there is a potential race condition where another mSyncTriggers runnable
- // can start running right after we get the tables list to sync.
- final int[] tablesToSync = mObservedTableTracker.getTablesToSync();
- if (tablesToSync == null) {
- return;
- }
- final int limit = tablesToSync.length;
- final SupportSQLiteDatabase writableDatabase = mDatabase.getOpenHelper()
- .getWritableDatabase();
- try {
- writableDatabase.beginTransaction();
- for (int tableId = 0; tableId < limit; tableId++) {
- switch (tablesToSync[tableId]) {
- case ObservedTableTracker.ADD:
- startTrackingTable(writableDatabase, tableId);
- break;
- case ObservedTableTracker.REMOVE:
- stopTrackingTable(writableDatabase, tableId);
- break;
- }
- }
- writableDatabase.setTransactionSuccessful();
- } finally {
- writableDatabase.endTransaction();
- }
- mObservedTableTracker.onSyncCompleted();
- }
- } catch (IllegalStateException | SQLiteException exception) {
- // may happen if db is closed. just log.
- Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?",
- exception);
- }
- }
- };
-
private boolean ensureInitialization() {
if (!mDatabase.isOpen()) {
return false;
@@ -444,6 +398,53 @@
mRefreshRunnable.run();
}
+ void syncTriggers(SupportSQLiteDatabase database) {
+ if (database.inTransaction()) {
+ // we won't run this inside another transaction.
+ return;
+ }
+ try {
+ // This method runs in a while loop because while changes are synced to db, another
+ // runnable may be skipped. If we cause it to skip, we need to do its work.
+ while (true) {
+ Lock closeLock = mDatabase.getCloseLock();
+ closeLock.lock();
+ try {
+ // there is a potential race condition where another mSyncTriggers runnable
+ // can start running right after we get the tables list to sync.
+ final int[] tablesToSync = mObservedTableTracker.getTablesToSync();
+ if (tablesToSync == null) {
+ return;
+ }
+ final int limit = tablesToSync.length;
+ try {
+ database.beginTransaction();
+ for (int tableId = 0; tableId < limit; tableId++) {
+ switch (tablesToSync[tableId]) {
+ case ObservedTableTracker.ADD:
+ startTrackingTable(database, tableId);
+ break;
+ case ObservedTableTracker.REMOVE:
+ stopTrackingTable(database, tableId);
+ break;
+ }
+ }
+ database.setTransactionSuccessful();
+ } finally {
+ database.endTransaction();
+ }
+ mObservedTableTracker.onSyncCompleted();
+ } finally {
+ closeLock.unlock();
+ }
+ }
+ } catch (IllegalStateException | SQLiteException exception) {
+ // may happen if db is closed. just log.
+ Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?",
+ exception);
+ }
+ }
+
/**
* Called by RoomDatabase before each beginTransaction call.
* <p>
@@ -453,7 +454,10 @@
* This api should eventually be public.
*/
void syncTriggers() {
- mSyncTriggers.run();
+ if (!mDatabase.isOpen()) {
+ return;
+ }
+ syncTriggers(mDatabase.getOpenHelper().getWritableDatabase());
}
/**
diff --git a/room/runtime/src/main/java/android/arch/persistence/room/RoomDatabase.java b/room/runtime/src/main/java/android/arch/persistence/room/RoomDatabase.java
index b2a9f0e..90131ed 100644
--- a/room/runtime/src/main/java/android/arch/persistence/room/RoomDatabase.java
+++ b/room/runtime/src/main/java/android/arch/persistence/room/RoomDatabase.java
@@ -243,8 +243,9 @@
*/
public void beginTransaction() {
assertNotMainThread();
- mInvalidationTracker.syncTriggers();
- mOpenHelper.getWritableDatabase().beginTransaction();
+ SupportSQLiteDatabase database = mOpenHelper.getWritableDatabase();
+ mInvalidationTracker.syncTriggers(database);
+ database.beginTransaction();
}
/**
diff --git a/room/runtime/src/test/java/android/arch/persistence/room/InvalidationTrackerTest.java b/room/runtime/src/test/java/android/arch/persistence/room/InvalidationTrackerTest.java
index d7474fd..ca091d5 100644
--- a/room/runtime/src/test/java/android/arch/persistence/room/InvalidationTrackerTest.java
+++ b/room/runtime/src/test/java/android/arch/persistence/room/InvalidationTrackerTest.java
@@ -125,13 +125,10 @@
public void addRemoveObserver() throws Exception {
InvalidationTracker.Observer observer = new LatchObserver(1, "a");
mTracker.addObserver(observer);
- drainTasks();
assertThat(mTracker.mObserverMap.size(), is(1));
mTracker.removeObserver(new LatchObserver(1, "a"));
- drainTasks();
assertThat(mTracker.mObserverMap.size(), is(1));
mTracker.removeObserver(observer);
- drainTasks();
assertThat(mTracker.mObserverMap.size(), is(0));
}
@@ -241,9 +238,9 @@
@Test
public void closedDb() {
+ doReturn(false).when(mRoomDatabase).isOpen();
doThrow(new IllegalStateException("foo")).when(mOpenHelper).getWritableDatabase();
mTracker.addObserver(new LatchObserver(1, "a", "b"));
- mTracker.syncTriggers();
mTracker.mRefreshRunnable.run();
}
diff --git a/samples/ViewPager2Demos/src/main/AndroidManifest.xml b/samples/ViewPager2Demos/src/main/AndroidManifest.xml
index 57e52d3..9dcc335 100644
--- a/samples/ViewPager2Demos/src/main/AndroidManifest.xml
+++ b/samples/ViewPager2Demos/src/main/AndroidManifest.xml
@@ -31,6 +31,13 @@
</intent-filter>
</activity>
+ <activity android:name=".CardFragmentActivity" android:label="CardsFragmentDemo">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.SAMPLE_CODE"/>
+ </intent-filter>
+ </activity>
+
<activity android:name=".BrowseActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
diff --git a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/CardActivity.java b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/CardActivity.java
index 7484389..7198df3 100644
--- a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/CardActivity.java
+++ b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/CardActivity.java
@@ -16,27 +16,28 @@
package com.example.androidx.widget.viewpager2;
-import static java.util.Arrays.asList;
import static java.util.Collections.unmodifiableList;
import android.app.Activity;
import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v7.widget.RecyclerView;
+import android.view.ViewGroup;
import com.example.androidx.widget.viewpager2.cards.Card;
-import com.example.androidx.widget.viewpager2.cards.CardDataAdapter;
+import com.example.androidx.widget.viewpager2.cards.CardView;
import java.util.List;
import androidx.widget.ViewPager2;
-/** @inheritDoc */
+/**
+ * Shows how to use {@link ViewPager2#setAdapter(RecyclerView.Adapter)}
+ *
+ * @see CardFragmentActivity
+ */
public class CardActivity extends Activity {
- private static final List<Card> sCards = unmodifiableList(asList(
- new Card('♦', 'A'),
- new Card('♣', 'K'),
- new Card('♥', 'J'),
- new Card('♠', '9'),
- new Card('♦', '2')));
+ private static final List<Card> sCards = unmodifiableList(Card.createDeck52());
@Override
public void onCreate(Bundle bundle) {
@@ -44,6 +45,38 @@
setContentView(R.layout.activity_card_layout);
this.<ViewPager2>findViewById(R.id.view_pager).setAdapter(
- new CardDataAdapter(getLayoutInflater(), sCards));
+ new RecyclerView.Adapter<CardViewHolder>() {
+ @NonNull
+ public CardViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
+ int viewType) {
+ return new CardViewHolder(new CardView(getLayoutInflater(), parent));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull CardViewHolder holder, int position) {
+ holder.bind(sCards.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return sCards.size();
+ }
+ });
+ }
+
+ /** @inheritDoc */
+ public static class CardViewHolder extends RecyclerView.ViewHolder {
+ private final CardView mCardView;
+
+ /** {@inheritDoc} */
+ public CardViewHolder(CardView cardView) {
+ super(cardView.getView());
+ mCardView = cardView;
+ }
+
+ /** @see CardView#bind(Card) */
+ public void bind(Card card) {
+ mCardView.bind(card);
+ }
}
}
diff --git a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/CardFragmentActivity.java b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/CardFragmentActivity.java
new file mode 100644
index 0000000..f981e67
--- /dev/null
+++ b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/CardFragmentActivity.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.androidx.widget.viewpager2;
+
+import static java.util.Collections.unmodifiableList;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentActivity;
+import android.support.v4.app.FragmentManager;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.example.androidx.widget.viewpager2.cards.Card;
+import com.example.androidx.widget.viewpager2.cards.CardView;
+
+import java.util.List;
+
+import androidx.widget.ViewPager2;
+import androidx.widget.ViewPager2.FragmentProvider;
+
+/**
+ * Shows how to use {@link ViewPager2#setAdapter(FragmentManager, FragmentProvider, int)}
+ *
+ * @see CardActivity
+ */
+public class CardFragmentActivity extends FragmentActivity {
+ private static final List<Card> sCards = unmodifiableList(Card.createDeck52());
+
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ setContentView(R.layout.activity_card_layout);
+
+ this.<ViewPager2>findViewById(R.id.view_pager).setAdapter(getSupportFragmentManager(),
+ new FragmentProvider() {
+ @Override
+ public Fragment getItem(int position) {
+ return CardFragment.create(sCards.get(position));
+ }
+
+ @Override
+ public int getCount() {
+ return sCards.size();
+ }
+ },
+ ViewPager2.FragmentRetentionPolicy.SAVE_STATE);
+ }
+
+ /** {@inheritDoc} */
+ public static class CardFragment extends Fragment {
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ CardView cardView = new CardView(getLayoutInflater(), container);
+ cardView.bind(Card.fromBundle(getArguments()));
+ return cardView.getView();
+ }
+
+ /** Creates a Fragment for a given {@link Card} */
+ public static CardFragment create(Card card) {
+ CardFragment fragment = new CardFragment();
+ fragment.setArguments(card.toBundle());
+ return fragment;
+ }
+ }
+}
diff --git a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/Card.java b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/Card.java
index 02f3271..38a0615 100644
--- a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/Card.java
+++ b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/Card.java
@@ -19,15 +19,21 @@
import static java.util.Arrays.asList;
import static java.util.Collections.unmodifiableSet;
+import android.os.Bundle;
+
+import java.util.ArrayList;
import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Set;
/**
* Playing card
*/
public class Card {
+ private static final String ARGS_BUNDLE = Card.class.getName() + ":Bundle";
+
private static final Set<Character> SUITS = unmodifiableSet(new LinkedHashSet<>(
- asList('♦', /* diamonds*/ '♣', /*, clubs*/ '♥', /* hearts*/ '♠' /*spades*/)));
+ asList('♣' /* clubs*/, '♦' /* diamonds*/, '♥' /* hearts*/, '♠' /*spades*/)));
private static final Set<Character> VALUES = unmodifiableSet(new LinkedHashSet<>(
asList('2', '3', '4', '5', '6', '7', '8', '9', 'â’‘', 'J', 'Q', 'K', 'A')));
@@ -39,18 +45,47 @@
this.mValue = checkValidValue(value, VALUES);
}
- public char getSuit() {
+ char getSuit() {
return mSuit;
}
- public String getCornerLabel() {
+ String getCornerLabel() {
return mValue + "\n" + mSuit;
}
+ /** Use in conjunction with {@link Card#fromBundle(Bundle)} */
+ public Bundle toBundle() {
+ Bundle args = new Bundle(1);
+ args.putCharArray(ARGS_BUNDLE, new char[]{mSuit, mValue});
+ return args;
+ }
+
+ /** Use in conjunction with {@link Card#toBundle()} */
+ public static Card fromBundle(Bundle bundle) {
+ char[] spec = bundle.getCharArray(ARGS_BUNDLE);
+ return new Card(spec[0], spec[1]);
+ }
+
private static char checkValidValue(char value, Set<Character> allowed) {
if (allowed.contains(value)) {
return value;
}
throw new IllegalArgumentException("Illegal argument: " + value);
}
+
+ /**
+ * Creates a deck of all allowed cards
+ */
+ public static List<Card> createDeck52() {
+ List<Card> result = new ArrayList<>(52);
+ for (Character suit : SUITS) {
+ for (Character value : VALUES) {
+ result.add(new Card(suit, value));
+ }
+ }
+ if (result.size() != 52) {
+ throw new IllegalStateException();
+ }
+ return result;
+ }
}
diff --git a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardDataAdapter.java b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardDataAdapter.java
deleted file mode 100644
index 7f69e1b..0000000
--- a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardDataAdapter.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.example.androidx.widget.viewpager2.cards;
-
-import android.support.annotation.NonNull;
-import android.support.v7.widget.RecyclerView;
-import android.view.LayoutInflater;
-import android.view.ViewGroup;
-
-import com.example.androidx.widget.viewpager2.R;
-
-import java.util.List;
-
-/** @inheritDoc */
-public class CardDataAdapter extends RecyclerView.Adapter<CardViewHolder> {
- private final List<Card> mCards;
- private final LayoutInflater mLayoutInflater;
-
- public CardDataAdapter(LayoutInflater layoutInflater, List<Card> cards) {
- mLayoutInflater = layoutInflater;
- mCards = cards;
- }
-
- /**
- * @inheritDoc
- */
- @NonNull
- public CardViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
- return new CardViewHolder(
- mLayoutInflater.inflate(R.layout.item_card_layout, parent, false));
- }
-
- @Override
- public void onBindViewHolder(@NonNull CardViewHolder holder, int position) {
- holder.apply(mCards.get(position));
- }
-
- @Override
- public int getItemCount() {
- return mCards.size();
- }
-}
diff --git a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardViewHolder.java b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardView.java
similarity index 63%
rename from samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardViewHolder.java
rename to samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardView.java
index 8fd0477..7c8533c 100644
--- a/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardViewHolder.java
+++ b/samples/ViewPager2Demos/src/main/java/com/example/androidx/widget/viewpager2/cards/CardView.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017 The Android Open Source Project
+ * Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,29 +16,31 @@
package com.example.androidx.widget.viewpager2.cards;
-import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
import android.view.View;
+import android.view.ViewGroup;
import android.widget.TextView;
import com.example.androidx.widget.viewpager2.R;
-/** @inheritDoc */
-public class CardViewHolder extends RecyclerView.ViewHolder {
+/** Inflates and populates a {@link View} representing a {@link Card} */
+public class CardView {
+ private final View mView;
private final TextView mTextSuite;
private final TextView mTextCorner1;
private final TextView mTextCorner2;
- public CardViewHolder(View itemView) {
- super(itemView);
- mTextSuite = itemView.findViewById(R.id.label_center);
- mTextCorner1 = itemView.findViewById(R.id.label_top);
- mTextCorner2 = itemView.findViewById(R.id.label_bottom);
+ public CardView(LayoutInflater layoutInflater, ViewGroup container) {
+ mView = layoutInflater.inflate(R.layout.item_card_layout, container, false);
+ mTextSuite = mView.findViewById(R.id.label_center);
+ mTextCorner1 = mView.findViewById(R.id.label_top);
+ mTextCorner2 = mView.findViewById(R.id.label_bottom);
}
/**
* Updates the view to represent the passed in card
*/
- public void apply(Card card) {
+ public void bind(Card card) {
mTextSuite.setText(Character.toString(card.getSuit()));
String cornerLabel = card.getCornerLabel();
@@ -46,4 +48,8 @@
mTextCorner2.setText(cornerLabel);
mTextCorner2.setRotation(180);
}
+
+ public View getView() {
+ return mView;
+ }
}
diff --git a/samples/ViewPager2Demos/src/main/res/layout/item_card_layout.xml b/samples/ViewPager2Demos/src/main/res/layout/item_card_layout.xml
index 90a0404..1ae9295 100644
--- a/samples/ViewPager2Demos/src/main/res/layout/item_card_layout.xml
+++ b/samples/ViewPager2Demos/src/main/res/layout/item_card_layout.xml
@@ -26,6 +26,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
+ android:gravity="center"
android:textAppearance="@android:style/TextAppearance.Medium"/>
<TextView
@@ -40,5 +41,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
+ android:gravity="center"
android:textAppearance="@android:style/TextAppearance.Medium"/>
</FrameLayout>
diff --git a/transition/proguard-rules.pro b/transition/proguard-rules.pro
index 6cae5e6..dda2c4e 100644
--- a/transition/proguard-rules.pro
+++ b/transition/proguard-rules.pro
@@ -12,10 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-# FragmentTransitionSupport is instantiated in support-fragment via reflection.
--keep public class android.support.transition.FragmentTransitionSupport {
-}
-
# Keep a field in transition that is used to keep a reference to weakly-referenced object
-keepclassmembers class android.support.transition.ChangeBounds$* extends android.animation.AnimatorListenerAdapter {
android.support.transition.ChangeBounds$ViewBounds mViewBounds;
diff --git a/viewpager2/build.gradle b/viewpager2/build.gradle
index 7f75d09..543a8d4 100644
--- a/viewpager2/build.gradle
+++ b/viewpager2/build.gradle
@@ -23,7 +23,7 @@
}
dependencies {
- api(project(":support-core-utils"))
+ api(project(":support-fragment"))
api(project(":recyclerview-v7"))
androidTestImplementation(TEST_RUNNER)
diff --git a/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/TestActivity.java b/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/TestActivity.java
index 351ad9a..bdc2ac9 100644
--- a/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/TestActivity.java
+++ b/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/TestActivity.java
@@ -16,12 +16,12 @@
package androidx.widget.viewpager2.tests;
-import android.app.Activity;
import android.os.Bundle;
+import android.support.v4.app.FragmentActivity;
import androidx.widget.viewpager2.test.R;
-public class TestActivity extends Activity {
+public class TestActivity extends FragmentActivity {
@Override
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
diff --git a/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/ViewPager2Tests.java b/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/ViewPager2Tests.java
index 45b42aa..6f22bae 100644
--- a/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/ViewPager2Tests.java
+++ b/viewpager2/src/androidTest/java/androidx/widget/viewpager2/tests/ViewPager2Tests.java
@@ -22,45 +22,61 @@
import static android.support.test.espresso.matcher.ViewMatchers.withId;
import static android.support.test.espresso.matcher.ViewMatchers.withText;
import static android.support.v7.widget.RecyclerView.SCROLL_STATE_IDLE;
-import static android.support.v7.widget.RecyclerView.SCROLL_STATE_SETTLING;
import static android.view.View.OVER_SCROLL_NEVER;
import static org.hamcrest.CoreMatchers.allOf;
-import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.lessThanOrEqualTo;
import android.content.Context;
import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
import android.os.Build;
+import android.os.Bundle;
import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
import android.support.test.InstrumentationRegistry;
-import android.support.test.espresso.IdlingRegistry;
import android.support.test.espresso.ViewAction;
import android.support.test.espresso.action.ViewActions;
-import android.support.test.espresso.idling.CountingIdlingResource;
import android.support.test.filters.MediumTest;
import android.support.test.rule.ActivityTestRule;
import android.support.test.runner.AndroidJUnit4;
+import android.support.v4.app.Fragment;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.Adapter;
import android.support.v7.widget.RecyclerView.ViewHolder;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
-import org.junit.After;
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
import androidx.widget.ViewPager2;
import androidx.widget.viewpager2.test.R;
@MediumTest
@RunWith(AndroidJUnit4.class)
public class ViewPager2Tests {
+ private static final Random RANDOM = new Random();
private static final int[] sColors = {
Color.parseColor("#BBA9FF00"),
Color.parseColor("#BB00E87E"),
@@ -74,7 +90,9 @@
public ExpectedException mExpectedException = ExpectedException.none();
private ViewPager2 mViewPager;
- private CountingIdlingResource mIdlingResource;
+
+ // allows to wait until swipe operation is finished (Smooth Scroller done)
+ private CountDownLatch mStableAfterSwipe;
public ViewPager2Tests() {
mActivityTestRule = new ActivityTestRule<>(TestActivity.class);
@@ -84,28 +102,342 @@
public void setUp() {
mViewPager = mActivityTestRule.getActivity().findViewById(R.id.view_pager);
- mIdlingResource = new CountingIdlingResource(getClass().getSimpleName() + "IdlingResource");
mViewPager.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
- if (newState == SCROLL_STATE_IDLE && !mIdlingResource.isIdleNow()) {
- mIdlingResource.decrement();
- } else if (newState == SCROLL_STATE_SETTLING && mIdlingResource.isIdleNow()) {
- mIdlingResource.increment();
+ // coming to idle from another state (dragging or setting) means we're stable now
+ if (newState == SCROLL_STATE_IDLE) {
+ mStableAfterSwipe.countDown();
}
}
});
- IdlingRegistry.getInstance().register(mIdlingResource);
+
+ final long seed = RANDOM.nextLong();
+ RANDOM.setSeed(seed);
+ Log.i(getClass().getName(), "Random seed: " + seed);
}
- @After
- public void tearDown() {
- IdlingRegistry.getInstance().unregister(mIdlingResource);
+ public static class PageFragment extends Fragment {
+ private static final String KEY_VALUE = "value";
+
+ public interface EventListener {
+ void onEvent(PageFragment fragment);
+
+ EventListener NO_OP = new EventListener() {
+ @Override
+ public void onEvent(PageFragment fragment) {
+ // do nothing
+ }
+ };
+ }
+
+ private EventListener mOnAttachListener = EventListener.NO_OP;
+ private EventListener mOnDestroyListener = EventListener.NO_OP;
+
+ private int mPosition;
+ private int mValue;
+
+ public static PageFragment create(int position, int value) {
+ PageFragment result = new PageFragment();
+ Bundle args = new Bundle(1);
+ args.putInt(KEY_VALUE, value);
+ result.setArguments(args);
+ result.mPosition = position;
+ return result;
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mOnAttachListener.onEvent(this);
+ }
+
+ @NonNull
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.item_test_layout, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ Bundle data = savedInstanceState != null ? savedInstanceState : getArguments();
+ setValue(data.getInt(KEY_VALUE));
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mOnDestroyListener.onEvent(this);
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ outState.putInt(KEY_VALUE, mValue);
+ }
+
+ public void setValue(int value) {
+ mValue = value;
+ TextView textView = getView().findViewById(R.id.text_view);
+ applyViewValue(textView, mValue);
+ }
+ }
+
+ private static void applyViewValue(TextView textView, int value) {
+ textView.setText(String.valueOf(value));
+ textView.setBackgroundColor(getColor(value));
+ }
+
+ private static int getColor(int value) {
+ return sColors[value % sColors.length];
}
@Test
- public void rendersAndHandlesSwiping() throws Throwable {
- final int pageCount = sColors.length;
+ public void fragmentAdapter_fullPass() throws Throwable {
+ testFragmentLifecycle(8, Arrays.asList(1, 2, 3, 4, 5, 6, 7, 6, 5, 4, 3, 2, 1, 0));
+ }
+
+ @Test
+ public void fragmentAdapter_random() throws Throwable {
+ final int totalPages = 10;
+ final int sequenceLength = 50;
+ testFragmentLifecycle_random(totalPages, sequenceLength, PageMutator.NO_OP);
+ }
+
+ @Test
+ public void fragmentAdapter_random_withMutations() throws Throwable {
+ final int totalPages = 10;
+ final int sequenceLength = 50;
+ testFragmentLifecycle_random(totalPages, sequenceLength, PageMutator.RANDOM);
+ }
+
+ private void testFragmentLifecycle_random(int totalPages, int sequenceLength,
+ PageMutator pageMutator) throws Throwable {
+ List<Integer> pageSequence = generateRandomPageSequence(totalPages, sequenceLength);
+
+ Log.i(getClass().getName(),
+ String.format("Testing with a sequence [%s]", TextUtils.join(", ", pageSequence)));
+
+ testFragmentLifecycle(totalPages, pageSequence, pageMutator);
+ }
+
+ @NonNull
+ private List<Integer> generateRandomPageSequence(int totalPages, int sequenceLength) {
+ List<Integer> pageSequence = new ArrayList<>(sequenceLength);
+
+ int pageIx = 0;
+ Double goRightProbability = null;
+ while (pageSequence.size() != sequenceLength) {
+ boolean goRight;
+ if (pageIx == 0) {
+ goRight = true;
+ goRightProbability = 0.7;
+ } else if (pageIx == totalPages - 1) { // last page
+ goRight = false;
+ goRightProbability = 0.3;
+ } else {
+ goRight = RANDOM.nextDouble() < goRightProbability;
+ }
+
+ pageSequence.add(goRight ? ++pageIx : --pageIx);
+ }
+
+ return pageSequence;
+ }
+
+ /**
+ * Test added when caught a bug: after the last swipe: actual=6, expected=4
+ * <p>
+ * Bug was caused by an invalid test assumption (new Fragment value can be inferred from number
+ * of instances created) - invalid in a case when we sometimes create Fragments off-screen and
+ * end up scrapping them.
+ **/
+ @Test
+ public void fragmentAdapter_regression1() throws Throwable {
+ testFragmentLifecycle(10, Arrays.asList(1, 2, 3, 2, 1, 2, 3, 4));
+ }
+
+ /**
+ * Test added when caught a bug: after the last swipe: actual=4, expected=5
+ * <p>
+ * Bug was caused by mSavedStates.add(position, ...) instead of mSavedStates.set(position, ...)
+ **/
+ @Test
+ public void fragmentAdapter_regression2() throws Throwable {
+ testFragmentLifecycle(10, Arrays.asList(1, 2, 3, 4, 3, 2, 1, 2, 3, 4, 5));
+ }
+
+ /**
+ * Test added when caught a bug: after the last swipe: ArrayIndexOutOfBoundsException: length=5;
+ * index=-1 at androidx.widget.viewpager2.tests.ViewPager2Tests$PageFragment.onCreateView
+ * <p>
+ * Bug was caused by always saving states of unattached fragments as null (even if there was a
+ * valid previously saved state)
+ */
+ @Test
+ public void fragmentAdapter_regression3() throws Throwable {
+ testFragmentLifecycle(10, Arrays.asList(1, 2, 3, 2, 1, 2, 3, 2, 1, 0));
+ }
+
+ /** Goes left on left edge / right on right edge */
+ @Test
+ public void fragmentAdapter_edges() throws Throwable {
+ testFragmentLifecycle(4, Arrays.asList(0, 0, 1, 2, 3, 3, 3, 2, 1, 0, 0, 0));
+ }
+
+ private interface PageMutator {
+ void mutate(PageFragment fragment);
+
+ PageMutator NO_OP = new PageMutator() {
+ @Override
+ public void mutate(PageFragment fragment) {
+ // do nothing
+ }
+ };
+
+ /** At random modifies the page under Fragment */
+ PageMutator RANDOM = new PageMutator() {
+ @Override
+ public void mutate(PageFragment fragment) {
+ Random random = ViewPager2Tests.RANDOM;
+ if (random.nextDouble() < 0.125) {
+ int delta = (1 + random.nextInt(5)) * sColors.length;
+ fragment.setValue(fragment.mValue + delta);
+ }
+ }
+ };
+ }
+
+ /** @see this#testFragmentLifecycle(int, List, PageMutator) */
+ private void testFragmentLifecycle(final int totalPages, List<Integer> pageSequence)
+ throws Throwable {
+ testFragmentLifecycle(totalPages, pageSequence, PageMutator.NO_OP);
+ }
+
+ /**
+ * Verifies:
+ * <ul>
+ * <li>page content / background
+ * <li>maximum number of Fragments held in memory
+ * <li>Fragment state saving / restoring
+ * </ul>
+ */
+ private void testFragmentLifecycle(final int totalPages, List<Integer> pageSequence,
+ final PageMutator pageMutator) throws Throwable {
+ final AtomicInteger attachCount = new AtomicInteger(0);
+ final AtomicInteger destroyCount = new AtomicInteger(0);
+ final boolean[] wasEverAttached = new boolean[totalPages];
+ final PageFragment[] fragments = new PageFragment[totalPages];
+
+ final int[] expectedValues = new int[totalPages];
+ for (int i = 0; i < totalPages; i++) {
+ expectedValues[i] = i;
+ }
+
+ mActivityTestRule.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mViewPager.setAdapter(mActivityTestRule.getActivity().getSupportFragmentManager(),
+ new ViewPager2.FragmentProvider() {
+ @Override
+ public Fragment getItem(final int position) {
+ // if the fragment was attached in the past, it means we have
+ // provided it with the correct value already; give a dummy one
+ // to prove state save / restore functionality works
+ int value = wasEverAttached[position] ? -1 : position;
+ PageFragment fragment = PageFragment.create(position, value);
+
+ fragment.mOnAttachListener = new PageFragment.EventListener() {
+ @Override
+ public void onEvent(PageFragment fragment) {
+ attachCount.incrementAndGet();
+ wasEverAttached[fragment.mPosition] = true;
+ }
+ };
+
+ fragment.mOnDestroyListener = new PageFragment.EventListener() {
+ @Override
+ public void onEvent(PageFragment fragment) {
+ destroyCount.incrementAndGet();
+ }
+ };
+
+ fragments[position] = fragment;
+ return fragment;
+ }
+
+ @Override
+ public int getCount() {
+ return totalPages;
+ }
+ }, ViewPager2.FragmentRetentionPolicy.SAVE_STATE);
+ }
+ });
+
+ final AtomicInteger currentPage = new AtomicInteger(0);
+ verifyView(expectedValues[currentPage.get()]);
+ for (int nextPage : pageSequence) {
+ swipe(currentPage.get(), nextPage, totalPages);
+ currentPage.set(nextPage);
+ verifyView(expectedValues[currentPage.get()]);
+
+ // TODO: validate Fragments that are instantiated, but not attached. No destruction
+ // steps are done to them - they're just left to the Garbage Collector. Maybe
+ // WeakReferences could help, but the GC behaviour is not predictable. Alternatively,
+ // we could only create Fragments onAttach, but there is a potential performance
+ // trade-off.
+ assertThat(attachCount.get() - destroyCount.get(), isBetween(1, 4));
+
+ mActivityTestRule.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ final int page = currentPage.get();
+ PageFragment fragment = fragments[page];
+ pageMutator.mutate(fragment);
+ expectedValues[page] = fragment.mValue;
+ }
+ });
+ }
+ }
+
+ private void swipe(int currentPageIx, int nextPageIx, int totalPages)
+ throws InterruptedException {
+ if (nextPageIx >= totalPages) {
+ throw new IllegalArgumentException("Invalid nextPageIx: >= totalPages.");
+ }
+
+ if (currentPageIx == nextPageIx) { // dedicated for testing edge behaviour
+ if (nextPageIx == 0) {
+ swipeRight(); // bounce off the left edge
+ return;
+ }
+ if (nextPageIx == totalPages - 1) { // bounce off the right edge
+ swipeLeft();
+ return;
+ }
+ throw new IllegalArgumentException(
+ "Invalid sequence. Not on an edge, and currentPageIx/nextPageIx pages same.");
+ }
+
+ if (Math.abs(nextPageIx - currentPageIx) > 1) {
+ throw new IllegalArgumentException(
+ "Specified nextPageIx not adjacent to the current page.");
+ }
+
+ if (nextPageIx > currentPageIx) {
+ swipeLeft();
+ } else {
+ swipeRight();
+ }
+ }
+
+ private Matcher<Integer> isBetween(int min, int max) {
+ return allOf(greaterThanOrEqualTo(min), lessThanOrEqualTo(max));
+ }
+
+ @Test
+ public void viewAdapter_rendersAndHandlesSwiping() throws Throwable {
+ final int totalPages = 8;
if (Build.VERSION.SDK_INT < 16) { // TODO(b/71500143): remove temporary workaround
RecyclerView mRecyclerView = (RecyclerView) mViewPager.getChildAt(0);
@@ -131,41 +463,66 @@
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
TextView view = (TextView) holder.itemView;
- view.setText(String.valueOf(position));
- view.setBackgroundColor(sColors[position]);
+ applyViewValue(view, position);
}
@Override
public int getItemCount() {
- return pageCount;
+ return totalPages;
}
});
}
});
- final int pageIxFirst = 0;
- final int pageIxLast = pageCount - 1;
- final int swipeCount = pageCount + 1; // two swipes beyond edge to test 'edge behavior'
- int pageNumber = pageIxFirst;
- for (int i = 0; i < swipeCount; i++, pageNumber = Math.min(pageIxLast, ++pageNumber)) {
- verifyView(pageNumber);
- performSwipe(ViewActions.swipeLeft());
+ List<Integer> pageSequence = Arrays.asList(0, 0, 1, 2, 3, 4, 5, 6, 7, 7, 7, 6, 5, 4, 3, 2,
+ 1, 0, 0, 0);
+ verifyView(0);
+ int currentPage = 0;
+ for (int nextPage : pageSequence) {
+ swipe(currentPage, nextPage, totalPages);
+ currentPage = nextPage;
+ verifyView(currentPage);
}
- assertThat(pageNumber, equalTo(pageIxLast));
- for (int i = 0; i < swipeCount; i++, pageNumber = Math.max(pageIxFirst, --pageNumber)) {
- verifyView(pageNumber);
- performSwipe(ViewActions.swipeRight());
- }
- assertThat(pageNumber, equalTo(pageIxFirst));
}
private void verifyView(int pageNumber) {
onView(allOf(withId(R.id.text_view), isDisplayed())).check(
- matches(withText(String.valueOf(pageNumber))));
+ matches(allOf(withText(String.valueOf(pageNumber)),
+ new BackgroundColorMatcher(pageNumber))));
+ }
+
+ private static class BackgroundColorMatcher extends BaseMatcher<View> {
+ private final int mPageNumber;
+
+ BackgroundColorMatcher(int pageNumber) {
+ mPageNumber = pageNumber;
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("should have background color: ").appendValue(
+ getColor(mPageNumber));
+ }
+
+ @Override
+ public boolean matches(Object item) {
+ ColorDrawable background = (ColorDrawable) ((View) item).getBackground();
+ return background.getColor() == getColor(mPageNumber);
+ }
+ }
+
+ private void swipeLeft() throws InterruptedException {
+ performSwipe(ViewActions.swipeLeft());
+ }
+
+ private void swipeRight() throws InterruptedException {
+ performSwipe(ViewActions.swipeRight());
}
private void performSwipe(ViewAction swipeAction) throws InterruptedException {
+ mStableAfterSwipe = new CountDownLatch(1);
onView(allOf(isDisplayed(), withId(R.id.text_view))).perform(swipeAction);
+ mStableAfterSwipe.await(1, TimeUnit.SECONDS);
}
@Test
@@ -211,4 +568,5 @@
// TODO: verify correct padding behavior
// TODO: add test for screen orientation change
+ // TODO: port some of the fragment adapter tests as view adapter tests
}
diff --git a/viewpager2/src/main/java/androidx/widget/ViewPager2.java b/viewpager2/src/main/java/androidx/widget/ViewPager2.java
index 9ebdea1..19c8d3d 100644
--- a/viewpager2/src/main/java/androidx/widget/ViewPager2.java
+++ b/viewpager2/src/main/java/androidx/widget/ViewPager2.java
@@ -18,11 +18,19 @@
import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
+import static java.lang.annotation.RetentionPolicy.CLASS;
+
import android.content.Context;
import android.graphics.Rect;
+import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.annotation.RequiresApi;
import android.support.annotation.RestrictTo;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentPagerAdapter;
+import android.support.v4.app.FragmentStatePagerAdapter;
+import android.support.v4.view.ViewCompat;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.PagerSnapHelper;
import android.support.v7.widget.RecyclerView;
@@ -32,6 +40,11 @@
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import java.lang.annotation.Retention;
+import java.util.ArrayList;
+import java.util.List;
/**
* Work in progress: go/viewpager2
@@ -86,7 +99,7 @@
}
/**
- * TODO(b/70663708): decide on an Adapter class (for now reusing RecyclerView.Adapter)
+ * TODO(b/70663708): decide on an Adapter class. Here supporting RecyclerView.Adapter.
*
* @see RecyclerView#setAdapter(Adapter)
*/
@@ -123,6 +136,174 @@
});
}
+ /**
+ * TODO(b/70663708): decide on an Adapter class. Here supporting {@link Fragment}s.
+ *
+ * @param fragmentRetentionPolicy allows for future parameterization of Fragment memory
+ * strategy, similar to what {@link FragmentPagerAdapter} and
+ * {@link FragmentStatePagerAdapter} provide.
+ */
+ public void setAdapter(FragmentManager fragmentManager, FragmentProvider fragmentProvider,
+ @FragmentRetentionPolicy int fragmentRetentionPolicy) {
+ if (fragmentRetentionPolicy != FragmentRetentionPolicy.SAVE_STATE) {
+ throw new IllegalArgumentException("Currently only SAVE_STATE policy is supported");
+ }
+
+ mRecyclerView.setAdapter(new FragmentStateAdapter(fragmentManager, fragmentProvider));
+ }
+
+ /**
+ * Similar in behavior to {@link FragmentStatePagerAdapter}
+ * <p>
+ * Lifecycle within {@link RecyclerView}:
+ * <ul>
+ * <li>{@link RecyclerView.ViewHolder} initially an empty {@link FrameLayout}, serves as a
+ * re-usable container for a {@link Fragment} in later stages.
+ * <li>{@link RecyclerView.Adapter#onBindViewHolder} we ask for a {@link Fragment} for the
+ * position. If we already have the fragment, or have previously saved its state, we use those.
+ * <li>{@link RecyclerView.Adapter#onAttachedToWindow} we attach the {@link Fragment} to a
+ * container.
+ * <li>{@link RecyclerView.Adapter#onViewRecycled} and
+ * {@link RecyclerView.Adapter#onFailedToRecycleView} we remove, save state, destroy the
+ * {@link Fragment}.
+ * </ul>
+ */
+ private static class FragmentStateAdapter extends RecyclerView.Adapter<FragmentViewHolder> {
+ private final List<Fragment.SavedState> mSavedStates = new ArrayList<>();
+ // TODO: handle current item's menuVisibility userVisibleHint as FragmentStatePagerAdapter
+
+ private final FragmentManager mFragmentManager;
+ private final FragmentProvider mFragmentProvider;
+
+ private FragmentStateAdapter(FragmentManager fragmentManager,
+ FragmentProvider fragmentProvider) {
+ this.mFragmentManager = fragmentManager;
+ this.mFragmentProvider = fragmentProvider;
+ }
+
+ @NonNull
+ @Override
+ public FragmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return FragmentViewHolder.create(parent);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull FragmentViewHolder holder, int position) {
+ if (ViewCompat.isAttachedToWindow(holder.getContainer())) {
+ // this should never happen; if it does, it breaks our assumption that attaching
+ // a Fragment can reliably happen inside onViewAttachedToWindow
+ throw new IllegalStateException(
+ String.format("View %s unexpectedly attached to a window.",
+ holder.getContainer()));
+ }
+
+ holder.mFragment = getFragment(position);
+ }
+
+ private Fragment getFragment(int position) {
+ Fragment fragment = mFragmentProvider.getItem(position);
+ if (mSavedStates.size() > position) {
+ Fragment.SavedState savedState = mSavedStates.get(position);
+ if (savedState != null) {
+ fragment.setInitialSavedState(savedState);
+ }
+ }
+ return fragment;
+ }
+
+ @Override
+ public void onViewAttachedToWindow(@NonNull FragmentViewHolder holder) {
+ if (holder.mFragment.isAdded()) {
+ return;
+ }
+ mFragmentManager.beginTransaction().add(holder.getContainer().getId(),
+ holder.mFragment).commitNowAllowingStateLoss();
+ }
+
+ @Override
+ public int getItemCount() {
+ return mFragmentProvider.getCount();
+ }
+
+ @Override
+ public void onViewRecycled(@NonNull FragmentViewHolder holder) {
+ removeFragment(holder);
+ }
+
+ @Override
+ public boolean onFailedToRecycleView(@NonNull FragmentViewHolder holder) {
+ // This happens when a ViewHolder is in a transient state (e.g. during custom
+ // animation). We don't have sufficient information on how to clear up what lead to
+ // the transient state, so we are throwing away the ViewHolder to stay on the
+ // conservative side.
+ removeFragment(holder);
+ return false; // don't recycle the view
+ }
+
+ private void removeFragment(@NonNull FragmentViewHolder holder) {
+ if (holder.mFragment == null) {
+ return; // fresh ViewHolder, nothing to do
+ }
+
+ int position = holder.getAdapterPosition();
+
+ if (holder.mFragment.isAdded()) {
+ while (mSavedStates.size() <= position) {
+ mSavedStates.add(null);
+ }
+ mSavedStates.set(position,
+ mFragmentManager.saveFragmentInstanceState(holder.mFragment));
+ }
+
+ mFragmentManager.beginTransaction().remove(
+ holder.mFragment).commitNowAllowingStateLoss();
+ holder.mFragment = null;
+ }
+ }
+
+ private static class FragmentViewHolder extends RecyclerView.ViewHolder {
+ private Fragment mFragment;
+
+ private FragmentViewHolder(FrameLayout container) {
+ super(container);
+ }
+
+ static FragmentViewHolder create(ViewGroup parent) {
+ FrameLayout container = new FrameLayout(parent.getContext());
+ container.setLayoutParams(
+ new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT));
+ container.setId(ViewCompat.generateViewId());
+ return new FragmentViewHolder(container);
+ }
+
+ FrameLayout getContainer() {
+ return (FrameLayout) itemView;
+ }
+ }
+
+ /**
+ * Provides {@link Fragment}s for pages
+ */
+ public interface FragmentProvider {
+ /**
+ * Return the Fragment associated with a specified position.
+ */
+ Fragment getItem(int position);
+
+ /**
+ * Return the number of pages available.
+ */
+ int getCount();
+ }
+
+ @Retention(CLASS)
+ @IntDef({FragmentRetentionPolicy.SAVE_STATE})
+ public @interface FragmentRetentionPolicy {
+ /** Approach similar to {@link FragmentStatePagerAdapter} */
+ int SAVE_STATE = 0;
+ }
+
@Override
public void onViewAdded(View child) {
// TODO(b/70666620): consider adding a support for Decor views